Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth/sso: make it possible to have a single SSO for every account #7709

Draft
wants to merge 35 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9c143b4
auth/sso: make it possible to have one SSO for every account
haraldschilly Jul 26, 2024
27f145d
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Jul 29, 2024
932007a
SSO/update_on_login: change email address if no account with that new…
haraldschilly Jul 29, 2024
0cc199c
next/set-email-address: syntax error SQL
haraldschilly Jul 29, 2024
56e7230
sso/update email on login: more debugging and double checking
haraldschilly Jul 29, 2024
32f6959
sso/update_on_login: figure out how to use a check_hook
haraldschilly Jul 29, 2024
4bf740c
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Jul 30, 2024
b5ce711
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Jul 30, 2024
681f6b9
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Aug 27, 2024
c85f74a
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Aug 28, 2024
9e5bf97
db/account: refactor checkRequiredSSO and use it to check, if modifyi…
haraldschilly Aug 28, 2024
aa62d53
Merge remote-tracking branch 'origin/fix-i18n-timetravel' into sso-ex…
haraldschilly Aug 28, 2024
6c1e4d2
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Aug 28, 2024
647477c
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Aug 29, 2024
86c72cf
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Sep 9, 2024
37b599f
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Sep 23, 2024
5b4540a
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Sep 24, 2024
2ba33f5
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Oct 8, 2024
d9e8ec5
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Oct 14, 2024
7a3da33
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Oct 24, 2024
d931e6e
sso: tweak the checkRequiredSSO logic (match "*" at the end, if no ot…
haraldschilly Oct 24, 2024
c5f09ad
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Oct 29, 2024
66fed2f
auth/sso: refactor/ehancing the logic for passport-login checks and r…
haraldschilly Oct 30, 2024
2912041
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 4, 2024
9df77b7
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 5, 2024
45ca9c6
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 6, 2024
c4fc593
server/db: fix merge issue
haraldschilly Nov 6, 2024
65be2b6
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 11, 2024
935c4bb
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 11, 2024
0a80424
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 14, 2024
8c68168
check required SSO: ensure we really deal with an email address and t…
haraldschilly Nov 14, 2024
94d11f5
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 18, 2024
4c66cc0
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Nov 29, 2024
91136b4
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Dec 2, 2024
7e4ab2f
Merge remote-tracking branch 'origin/master' into sso-exclusive-all
haraldschilly Jan 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/packages/database/postgres-server-queries.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ read = require('read')
{pii_expire} = require("./postgres/pii")
passwordHash = require("@cocalc/backend/auth/password-hash").default;
registrationTokens = require('./postgres/registration-tokens').default;
getStrategiesSSO = require("@cocalc/database/settings/get-sso-strategies").default;
{updateUnreadMessageCount} = require('./postgres/messages');

stripe_name = require('@cocalc/util/stripe/name').default;
Expand Down Expand Up @@ -2599,6 +2600,19 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext
return result.rows[0].organization_id
return undefined

getStrategiesSSO: () =>
return await getStrategiesSSO()

get_email_address_for_account_id: (account_id) =>
result = await @async_query
query : 'SELECT email_address FROM accounts'
where : [ 'account_id :: UUID = $1' ]
params : [ account_id ]
if result.rows.length > 0
result.rows[0].email_address
else
return undefined

# async
registrationTokens: (options, query) =>
return await registrationTokens(@, options, query)
Expand Down
16 changes: 5 additions & 11 deletions src/packages/database/postgres/account-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
len,
} from "@cocalc/util/misc";
import { is_a_site_license_manager } from "./site-license/search";
import { PostgreSQL } from "./types";
import { PostgreSQL, SetAccountFields } from "./types";
//import getLogger from "@cocalc/backend/logger";
//const L = getLogger("db:pg:account-queries");

Expand Down Expand Up @@ -44,26 +44,18 @@ export async function is_paying_customer(
return await is_a_site_license_manager(db, account_id);
}

interface SetAccountFields {
db: PostgreSQL;
account_id: string;
email_address?: string | undefined;
first_name?: string | undefined;
last_name?: string | undefined;
}

// this is like set_account_info_if_different, but only sets the fields if they're not set
export async function set_account_info_if_not_set(
opts: SetAccountFields,
): Promise<void> {
): Promise<{ email_changed: boolean }> {
return await set_account_info_if_different(opts, false);
}

// This sets the given fields of an account, if it is different from the current value – except for the email address, which we only set but not change
export async function set_account_info_if_different(
opts: SetAccountFields,
overwrite = true,
): Promise<void> {
): Promise<{ email_changed: boolean }> {
const columns = ["email_address", "first_name", "last_name"];

// this could throw an error for "no such account"
Expand Down Expand Up @@ -105,6 +97,8 @@ export async function set_account_info_if_different(
account_id: opts.account_id,
});
}

return { email_changed: !!do_email };
}

export async function set_account(
Expand Down
66 changes: 48 additions & 18 deletions src/packages/database/postgres/passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,27 @@
import { PassportStrategyDB } from "@cocalc/database/settings/auth-sso-types";
import {
getPassportsCached,
setPassportsCached
setPassportsCached,
} from "@cocalc/database/settings/server-settings";
import { callback2 as cb2 } from "@cocalc/util/async-utils";
import { to_json } from "@cocalc/util/misc";
import { CB } from "@cocalc/util/types/database";
import {
set_account_info_if_different,
set_account_info_if_not_set,
set_email_address_verified
set_email_address_verified,
} from "./account-queries";
import {
CreatePassportOpts,
PassportExistsOpts,
PostgreSQL,
UpdateAccountInfoAndPassportOpts
SetAccountFields,
UpdateAccountInfoAndPassportOpts,
} from "./types";

export async function set_passport_settings(
db: PostgreSQL,
opts: PassportStrategyDB & { cb?: CB }
opts: PassportStrategyDB & { cb?: CB },
): Promise<void> {
const { strategy, conf, info } = opts;
let err = null;
Expand All @@ -50,7 +52,7 @@ export async function set_passport_settings(

export async function get_passport_settings(
db: PostgreSQL,
opts: { strategy: string; cb?: (data: object) => void }
opts: { strategy: string; cb?: (data: object) => void },
): Promise<any> {
const { rows } = await db.async_query({
query: "SELECT conf, info FROM passport_settings",
Expand All @@ -63,7 +65,7 @@ export async function get_passport_settings(
}

export async function get_all_passport_settings(
db: PostgreSQL
db: PostgreSQL,
): Promise<PassportStrategyDB[]> {
return (
await db.async_query<PassportStrategyDB>({
Expand All @@ -73,7 +75,7 @@ export async function get_all_passport_settings(
}

export async function get_all_passport_settings_cached(
db: PostgreSQL
db: PostgreSQL,
): Promise<PassportStrategyDB[]> {
const passports = getPassportsCached();
if (passports != null) {
Expand Down Expand Up @@ -103,7 +105,7 @@ export function _passport_key(opts) {

export async function create_passport(
db: PostgreSQL,
opts: CreatePassportOpts
opts: CreatePassportOpts,
): Promise<void> {
const dbg = db._dbg("create_passport");
dbg({ id: opts.id, strategy: opts.strategy, profile: to_json(opts.profile) });
Expand All @@ -121,10 +123,10 @@ export async function create_passport(
});

dbg(
`setting other account info ${opts.account_id}: ${opts.email_address}, ${opts.first_name}, ${opts.last_name}`
`setting other account info ${opts.account_id}: ${opts.email_address}, ${opts.first_name}, ${opts.last_name}`,
);
await set_account_info_if_not_set({
db: db,
db,
account_id: opts.account_id,
email_address: opts.email_address,
first_name: opts.first_name,
Expand All @@ -150,7 +152,7 @@ export async function create_passport(

export async function passport_exists(
db: PostgreSQL,
opts: PassportExistsOpts
opts: PassportExistsOpts,
): Promise<string | undefined> {
try {
const result = await db.async_query({
Expand All @@ -176,27 +178,45 @@ export async function passport_exists(
}
}

// this is only used in passport-login/maybeUpdateAccountAndPassport!
export async function update_account_and_passport(
db: PostgreSQL,
opts: UpdateAccountInfoAndPassportOpts
opts: UpdateAccountInfoAndPassportOpts,
) {
// we deliberately do not update the email address, because if the SSO
// strategy sends a different one, this would break the "link".
// rather, if the email (and hence most likely the email address) changes on the
// SSO side, this would equal to creating a new account.
// This also updates the email address, if it is set in opts and does not exist with another account yet.
// NOTE: this changed in July 2024. Prior to that, changing the email address of the same account (by ID) in SSO,
// would not change the email address.
const dbg = db._dbg("update_account_and_passport");
dbg(
`updating account info ${to_json({
first_name: opts.first_name,
last_name: opts.last_name,
})}`
email_addres: opts.email_address,
})}`,
);
await set_account_info_if_different({

const upd: SetAccountFields = {
db: db,
account_id: opts.account_id,
first_name: opts.first_name,
last_name: opts.last_name,
};

// Most likely, this just returns the very same account (since the account already exists).
const existing_account_id = await cb2(db.account_exists, {
email_address: opts.email_address,
});

if (!existing_account_id) {
// There is no account with the new email address, hence we can update the email address as well
upd.email_address = opts.email_address;
dbg(
`No existing account with email address ${opts.email_address}. Therefore, we change the email address of account ${opts.account_id} as well.`,
);
}

// this set_account_info_if_different checks again if the email exists on another account, but it would throw an error.
const { email_changed } = await set_account_info_if_different(upd);
const key = _passport_key(opts);
dbg(`updating passport ${to_json({ key, profile: opts.profile })}`);
await db.async_query({
Expand All @@ -208,4 +228,14 @@ export async function update_account_and_passport(
"account_id = $::UUID": opts.account_id,
},
});

// since we update the email address of an account based on a change from the SSO mechanism
// we can assume the new email address is also "verified"
if (email_changed && typeof upd.email_address === "string") {
await set_email_address_verified({
db,
account_id: opts.account_id,
email_address: upd.email_address,
});
}
}
12 changes: 12 additions & 0 deletions src/packages/database/postgres/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
QueryRows,
UntypedQueryResult,
} from "@cocalc/util/types/database";
import type { Strategy } from "@cocalc/util/types/sso";
import { Changes } from "./changefeed";

export type { QueryResult };
Expand Down Expand Up @@ -104,6 +105,7 @@ export interface UpdateAccountInfoAndPassportOpts {
id: string;
profile: any;
passport_profile: any;
email_address?: string;
}

export interface PostgreSQL extends EventEmitter {
Expand Down Expand Up @@ -323,6 +325,8 @@ export interface PostgreSQL extends EventEmitter {
}>;
}): Promise<void>;

getStrategiesSSO(): Promise<Strategy[]>;

user_query_cancel_changefeed(opts: { id: any; cb?: CB }): void;

save_blob(opts: {
Expand Down Expand Up @@ -370,5 +374,13 @@ export interface PostgreSQL extends EventEmitter {
}) => Promise<number | undefined>;
}

export interface SetAccountFields {
db: PostgreSQL;
account_id: string;
email_address?: string | undefined;
first_name?: string | undefined;
last_name?: string | undefined;
}

// This is an extension of BaseProject in projects/control/base.ts
type Project = EventEmitter & {};
2 changes: 1 addition & 1 deletion src/packages/database/settings/auth-sso-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export interface PassportStrategyDBConfig {
* - description: A longer description of the strategy, could be markdown, shown on the dedicated /sso/... pages.
* - icon: A URL to an icon
* - disabled: if true, this is ignored during the initialization
* - update_on_login: if true, the user's profile is updated on login (first and last name, not email)
* - update_on_login: if true, the user's profile is updated on login (first and last name, not email) and NOT by the user.
* - cookie_ttl_s: how long the remember_me cookied lasts (default is a month or so).
* This could be set to a much shorter period to force users more frequently to re-login.
*/
Expand Down
4 changes: 3 additions & 1 deletion src/packages/database/settings/get-sso-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export default async function getStrategies(): Promise<Strategy[]> {
COALESCE(info -> 'display', conf -> 'display') as display,
COALESCE(info -> 'public', conf -> 'public') as public,
COALESCE(info -> 'exclusive_domains', conf -> 'exclusive_domains') as exclusive_domains,
COALESCE(info -> 'do_not_hide', 'false'::JSONB) as do_not_hide
COALESCE(info -> 'do_not_hide', 'false'::JSONB) as do_not_hide,
COALESCE(info -> 'update_on_login', 'false'::JSONB) as update_on_login
FROM passport_settings
WHERE strategy != 'site_conf'
Expand All @@ -39,6 +40,7 @@ export default async function getStrategies(): Promise<Strategy[]> {
public: row.public ?? true,
exclusiveDomains: row.exclusive_domains ?? [],
doNotHide: row.do_not_hide ?? false,
updateOnLogin: row.update_on_login ?? false,
};
});
}
Expand Down
3 changes: 1 addition & 2 deletions src/packages/next/components/account/config/account/name.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function ConfigureName() {
type="error"
showIcon
/>
)}{" "}
)}
{get.loading ? (
<Loading />
) : (
Expand Down Expand Up @@ -131,7 +131,6 @@ function ConfigureName() {
for content you share publicly.
{original.name && (
<>
{" "}
Setting a username provides optional nicer URL's for shared
public documents. Your username can be between 1 and 39
characters, contain upper and lower case letters, numbers, and
Expand Down
3 changes: 2 additions & 1 deletion src/packages/next/components/auth/sso.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { join } from "path";
import { CSSProperties, ReactNode, useMemo } from "react";

import { Icon } from "@cocalc/frontend/components/icon";
import { checkRequiredSSO } from "@cocalc/server/auth/sso/check-required-sso";
import { checkRequiredSSO } from "@cocalc/util/auth-check-required-sso";
import { PRIMARY_SSO } from "@cocalc/util/types/passport-types";
import { Strategy } from "@cocalc/util/types/sso";
import Loading from "components/share/loading";
Expand Down Expand Up @@ -67,6 +67,7 @@ export default function SSO(props: SSOProps) {
public: true,
exclusiveDomains: [],
doNotHide: true,
updateOnLogin: false,
};

return (
Expand Down
12 changes: 7 additions & 5 deletions src/packages/next/components/misc/save-button.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
import { cloneDeep, debounce, isEqual } from "lodash";
import { Alert, Button, Space } from "antd";
import useIsMounted from "lib/hooks/mounted";
import { cloneDeep, debounce, isEqual } from "lodash";
import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";

import Loading from "components/share/loading";
import api from "lib/api/post";
import useIsMounted from "lib/hooks/mounted";

import { Icon } from "@cocalc/frontend/components/icon";
import { SCHEMA } from "@cocalc/util/schema";
import { keys } from "@cocalc/util/misc";
import { SCHEMA } from "@cocalc/util/schema";

interface Props {
edited: any;
Expand Down Expand Up @@ -119,7 +121,7 @@ export default function SaveButton({

const doSaveDebounced = useMemo(
() => debounce(doSave, debounce_ms),
[onSave]
[onSave],
);

useEffect(() => {
Expand Down
8 changes: 4 additions & 4 deletions src/packages/next/pages/api/v2/auth/sign-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,16 @@ import { v4 } from "uuid";
import { getServerSettings } from "@cocalc/database/settings/server-settings";
import createAccount from "@cocalc/server/accounts/create-account";
import isAccountAvailable from "@cocalc/server/auth/is-account-available";
import isDomainExclusiveSSO from "@cocalc/server/auth/is-domain-exclusive-sso";
import passwordStrength from "@cocalc/server/auth/password-strength";
import reCaptcha from "@cocalc/server/auth/recaptcha";
import { isExclusiveSSOEmail } from "@cocalc/server/auth/throttle";
import redeemRegistrationToken from "@cocalc/server/auth/tokens/redeem";
import sendWelcomeEmail from "@cocalc/server/email/welcome-email";
import getSiteLicenseId from "@cocalc/server/public-paths/site-license-id";
import {
is_valid_email_address as isValidEmailAddress,
len,
} from "@cocalc/util/misc";

import getAccountId from "lib/account/get-account";
import { apiRoute, apiRouteOperation } from "lib/api";
import assertTrusted from "lib/api/assert-trusted";
Expand Down Expand Up @@ -171,11 +170,12 @@ export async function signUp(req, res) {
});
return;
}
const exclusive = await isDomainExclusiveSSO(email);
const exclusive = await isExclusiveSSOEmail(email);
if (exclusive) {
const name = exclusive.display ?? exclusive.name;
res.json({
issues: {
email: `To sign up with "@${exclusive}", you have to use the corresponding single sign on mechanism. Delete your email address above, then click the SSO icon.`,
email: `To sign up with "@${name}", you have to use the corresponding single sign on mechanism. Delete your email address above, then click the SSO icon.`,
},
});
return;
Expand Down
Loading
Loading