@cocalc/database
Version:
CoCalc: code for working with our PostgreSQL database
248 lines (230 loc) • 6.55 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
// DEVELOPMENT: use scripts/auth/gen-sso.py to generate some test data
import {
getPassportsCached,
setPassportsCached,
} from "@cocalc/server/settings/server-settings";
import { to_json } from "@cocalc/util/misc";
import {
set_account_info_if_different,
set_account_info_if_not_set,
set_email_address_verified,
} from "./account-queries";
import {
CB,
CreatePassportOpts,
DeletePassportOpts,
PassportExistsOpts,
PostgreSQL,
UpdateAccountInfoAndPassportOpts,
} from "./types";
import { PassportStrategyDB } from "@cocalc/server/auth/sso/types";
import { isBlockedUnlinkStrategy } from "@cocalc/server/auth/sso/unlink-strategy";
export async function set_passport_settings(
db: PostgreSQL,
opts: PassportStrategyDB & { cb?: CB }
): Promise<void> {
const { strategy, conf, info } = opts;
let err = null;
try {
await db.async_query({
query: "INSERT INTO passport_settings",
values: {
"strategy::TEXT ": strategy,
"conf ::JSONB": conf,
"info ::JSONB": info,
},
conflict: "strategy",
});
} catch (err) {
err = err;
}
if (typeof opts.cb === "function") {
opts.cb(err);
}
}
export async function get_passport_settings(
db: PostgreSQL,
opts: { strategy: string; cb?: CB }
): Promise<any> {
const { rows } = await db.async_query({
query: "SELECT conf, info FROM passport_settings",
where: { "strategy = $::TEXT": opts.strategy },
});
if (typeof opts.cb === "function") {
opts.cb(rows[0]);
}
return rows[0];
}
export async function get_all_passport_settings(
db: PostgreSQL
): Promise<PassportStrategyDB[]> {
return (
await db.async_query({
query: "SELECT strategy, conf, info FROM passport_settings",
})
).rows;
}
export async function get_all_passport_settings_cached(
db: PostgreSQL
): Promise<PassportStrategyDB[]> {
const passports = getPassportsCached();
if (passports != null) {
return passports;
}
const res = await get_all_passport_settings(db);
setPassportsCached(res);
return res;
}
// Passports -- accounts linked to Google/Dropbox/Facebook/Github, etc.
// The Schema is slightly redundant, but indexed properly:
// {passports:['google-id', 'facebook-id'], passport_profiles:{'google-id':'...', 'facebook-id':'...'}}
export function _passport_key(opts) {
const { strategy, id } = opts;
// note: strategy is *our* name of the strategy in the DB, not it's type string!
if (typeof strategy !== "string") {
throw new Error("_passport_key: strategy must be defined");
}
if (typeof id !== "string") {
throw new Error("_passport_key: id must be defined");
}
return `${strategy}-${id}`;
}
export async function create_passport(
db: PostgreSQL,
opts: CreatePassportOpts
): Promise<void> {
const dbg = db._dbg("create_passport");
dbg({ id: opts.id, strategy: opts.strategy, profile: to_json(opts.profile) });
try {
dbg("setting the passport for the account");
await db.async_query({
query: "UPDATE accounts",
jsonb_set: {
passports: { [_passport_key(opts)]: opts.profile },
},
where: {
"account_id = $::UUID": opts.account_id,
},
});
dbg(
`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,
account_id: opts.account_id,
email_address: opts.email_address,
first_name: opts.first_name,
last_name: opts.last_name,
});
// we still record that email address as being verified
if (opts.email_address != null) {
await set_email_address_verified({
db,
account_id: opts.account_id,
email_address: opts.email_address,
});
}
opts.cb?.(undefined); // all good
} catch (err) {
if (opts.cb != null) {
opts.cb(err);
} else {
throw err;
}
}
}
export async function delete_passport(
db: PostgreSQL,
opts: DeletePassportOpts
) {
db._dbg("delete_passport")(to_json({ strategy: opts.strategy, id: opts.id }));
if (
await isBlockedUnlinkStrategy({
strategyName: opts.strategy,
account_id: opts.account_id,
})
) {
const err_msg = `You are not allowed to unlink '${opts.strategy}'`;
if (typeof opts.cb === "function") {
opts.cb(err_msg);
return;
} else {
throw new Error(err_msg);
}
}
return db._query({
query: "UPDATE accounts",
jsonb_set: {
// delete it
passports: { [_passport_key(opts)]: null },
},
where: {
"account_id = $::UUID": opts.account_id,
},
cb: opts.cb,
});
}
export async function passport_exists(
db: PostgreSQL,
opts: PassportExistsOpts
): Promise<string | undefined> {
try {
const result = await db.async_query({
query: "SELECT account_id FROM accounts",
where: [
// this uses the corresponding index to only scan a subset of all accounts!
"passports IS NOT NULL",
{ "(passports->>$::TEXT) IS NOT NULL": _passport_key(opts) },
],
});
const account_id = result?.rows[0]?.account_id;
if (opts.cb != null) {
opts.cb(null, account_id);
} else {
return account_id;
}
} catch (err) {
if (opts.cb != null) {
opts.cb(err);
} else {
throw err;
}
}
}
export async function update_account_and_passport(
db: PostgreSQL,
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.
const dbg = db._dbg("update_account_and_passport");
dbg(
`updating account info ${to_json({
first_name: opts.first_name,
last_name: opts.last_name,
})}`
);
await set_account_info_if_different({
db: db,
account_id: opts.account_id,
first_name: opts.first_name,
last_name: opts.last_name,
});
const key = _passport_key(opts);
dbg(`updating passport ${to_json({ key, profile: opts.profile })}`);
await db.async_query({
query: "UPDATE accounts",
jsonb_set: {
passports: { [key]: opts.profile },
},
where: {
"account_id = $::UUID": opts.account_id,
},
});
}