/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ // Passport Authentication (oauth, etc.) // // Server-side setup // ----------------- // // In order to get this running, you have to manually setup each service. // That requires to register with the authentication provider, telling them about CoCalc, // the domain you use, the return path for the response, and adding the client identification // and corresponding secret keys to the database. // Then, the service is active and will be presented to the user on the sign up page. // The following is an example for setting up google oauth. // The other services are similar. // // 1. background: https://developers.google.com/identity/sign-in/web/devconsole-project // 2. https://console.cloud.google.com/apis/credentials/consent // 3. https://console.developers.google.com/apis/credentials → create credentials → oauth, ... // 4. The return path for google is https://{DOMAIN_NAME}/auth/google/return // 5. When done, there should be an entry under "OAuth 2.0 client IDs" // 6. ... and you have your ID and secret! // // Now, connect to the database, where the setup is in the passports_settings table: // // In older code, there was a "site_conf". We fix it to be $base_path/auth. There is no need to configure it, and existing configurations are ignored. Besides that, it wasn't properly used for all SSO strategies anyways … // // What's important is to configure the individual passport settings: // // 2. insert into passport_settings (strategy , conf ) VALUES ( 'google', '{"clientID": "....apps.googleusercontent.com", "clientSecret": "..."}'::JSONB ) // // Then restart the hubs. import passwordHash, { verifyPassword, } from "@cocalc/backend/auth/password-hash"; import base_path from "@cocalc/backend/base-path"; import type { PostgreSQL } from "@cocalc/database/postgres/types"; import { getLogger } from "@cocalc/hub/logger"; import { getExtraStrategyConstructor } from "@cocalc/server/auth/sso/extra-strategies"; import { loadSSOConf } from "@cocalc/database/postgres/load-sso-conf"; import { addUserProfileCallback } from "@cocalc/server/auth/sso/oauth2-user-profile-callback"; import { PassportLogin } from "@cocalc/server/auth/sso/passport-login"; import { InitPassport, isOAuth2, PassportLoginOpts, PassportManagerOpts, PassportStrategyDB, PassportStrategyDBConfig, PassportTypes, StrategyConf, StrategyInstanceOpts, } from "@cocalc/server/auth/sso/types"; import { callback2 as cb2 } from "@cocalc/util/async-utils"; import * as misc from "@cocalc/util/misc"; import { DNS } from "@cocalc/util/theme"; import { PassportStrategyFrontend, PRIMARY_SSO, } from "@cocalc/util/types/passport-types"; import Cookies from "cookies"; import * as dot from "dot-object"; import * as express from "express"; import express_session from "express-session"; import * as _ from "lodash"; import ms from "ms"; import passport from "passport"; import { join as path_join } from "path"; import { v4 as uuidv4, v4 } from "uuid"; import { email_verification_problem, email_verified_successfully, welcome_email, } from "./email"; //import Saml2js from "saml2js"; import { getOauthCache, getPassportCache, } from "@cocalc/database/postgres/passport-store"; import { API_KEY_COOKIE_NAME, BLACKLISTED_STRATEGIES, DEFAULT_LOGIN_INFO, } from "@cocalc/server/auth/sso/consts"; import { FacebookStrategyConf, GithubStrategyConf, GoogleStrategyConf, TwitterStrategyConf, } from "@cocalc/server/auth/sso/public-strategies"; const sign_in = require("./sign-in"); const safeJsonStringify = require("safe-json-stringify"); const logger = getLogger("hub:auth"); // primary strategies -- all other ones are "extra" const PRIMARY_STRATEGIES = ["email", "site_conf", ...PRIMARY_SSO] as const; // root for authentication related endpoints -- will be prefixed with the base_path const AUTH_BASE = "/auth"; const { defaults, required } = misc; // singleton let pp_manager: PassportManager | null = null; export function get_passport_manager() { return pp_manager; } export async function init_passport(opts: InitPassport) { opts = defaults(opts, { router: required, database: required, host: required, cb: required, }); try { if (pp_manager == null) { pp_manager = new PassportManager(opts); await pp_manager.init(); } opts.cb(); } catch (err) { opts.cb(err); } } export class PassportManager { // express js, passed in from hub's main file private readonly router: express.Router; // the database, for various server queries private readonly database: PostgreSQL; // set in the hub, passed in -- not used by "site_conf", though private readonly host: string; // e.g. 127.0.0.1 // configured strategies private passports: { [k: string]: PassportStrategyDB } | undefined = undefined; // prefix for those endpoints, where SSO services return back private auth_url: string | undefined = undefined; constructor(opts: PassportManagerOpts) { const { router, database, host } = opts; this.handle_get_api_key.bind(this); this.router = router; this.database = database; this.host = host; } private async init_passport_settings(): Promise<{ [k: string]: PassportStrategyDB; }> { if (this.passports != null) { logger.debug("already initialized -- just returning what we have"); return this.passports; } try { // email is always included, if even email singup is disabled // use "register tokens" to restrict this method this.passports = { email: { strategy: "email", conf: { type: "email" }, info: { public: true }, }, }; const settings = await this.database.get_all_passport_settings(); for (const setting of settings) { const name = setting.strategy; if (BLACKLISTED_STRATEGIES.includes(name as any)) { throw new Error( `It is not allowed to name a strategy endpoint "${name}", because it is used by the next.js /auth/* endpoint. See next/pages/auth/ROUTING.md for more information.` ); } // backwards compatibility const conf = setting.conf as any; setting.info = setting.info ?? {}; if (setting.info.disabled ?? conf?.disabled ?? false) { continue; } for (const deprecated of [ "public", "display", "icon", "exclusive_domains", ]) { if (setting.info[deprecated] == null) { setting.info[deprecated] = conf?.[deprecated]; } } this.passports[setting.strategy] = setting; } return this.passports; } catch (err) { logger.debug(`error getting passport settings -- ${err}`); throw err; } return {}; } // Define handler for api key cookie setting. private handle_get_api_key(req, res, next) { if (req.query.get_api_key) { logger.debug("handle_get_api_key"); const cookies = new Cookies(req, res); // maxAge: User gets up to 60 minutes to go through the SSO process... cookies.set(API_KEY_COOKIE_NAME, req.query.get_api_key, { maxAge: 30 * 60 * 1000, }); } next(); } // this is for pure backwards compatibility. at some point remove this! // it only returns a string[] array of the legacy authentication strategies private strategies_v1(res): void { const data: string[] = []; const known = ["email", ...PRIMARY_SSO]; for (const name in this.passports) { if (name === "site_conf") continue; if (known.indexOf(name) >= 0) { data.push(name); } } res.json(data); } public get_strategies_v2(): PassportStrategyFrontend[] { const data: PassportStrategyFrontend[] = []; // we cast the result of _.pick to get more type saftey const keys = [ "display", "type", "icon", "public", "exclusive_domains", "do_not_hide", ] as const; for (const name in this.passports) { if (name === "site_conf") continue; // this is sent to the web client → do not include any secret info! const info: PassportStrategyFrontend = { name, ...(_.pick(this.passports[name].info, keys) as { [key in typeof keys[number]]: any; }), }; data.push(info); } return data; } // version 2 tells the web client a little bit more. // the additional info is used to render customizeable SSO icons. private strategies_v2(res): void { res.json(this.get_strategies_v2()); } async init(): Promise { // Initialize authentication plugins using Passport logger.debug("init"); // initialize use of middleware this.router.use(express_session({ secret: v4() })); // secret is totally random and per-hub session this.router.use(passport.initialize()); this.router.use(passport.session()); // Define user serialization passport.serializeUser((user, done) => done(null, user)); passport.deserializeUser((user: Express.User, done) => done(null, user)); await loadSSOConf(this.database); // this.router endpoints setup this.init_strategies_endpoint(); this.init_email_verification(); this.init_password_reset_token(); // prerequisite for setting up any SSO endpoints await this.init_passport_settings(); this.check_exclusive_domains_unique(); const settings = await cb2(this.database.get_server_settings_cached); const dns = settings.dns || DNS; this.auth_url = `https://${dns}${path_join(base_path, AUTH_BASE)}`; logger.debug(`auth_url='${this.auth_url}'`); await Promise.all([ this.initStrategy(GoogleStrategyConf), this.initStrategy(GithubStrategyConf), this.initStrategy(FacebookStrategyConf), this.initStrategy(TwitterStrategyConf), this.init_extra_strategies(), ]); } // check if exclusive domains are unique private check_exclusive_domains_unique() { const ret: { [k: string]: string } = {}; for (const k in this.passports) { const v = this.passports[k]; for (const domain of v.info?.exclusive_domains ?? []) { if (ret[domain] != null) { throw new Error( `exclusive domain '${domain}' defined by ${ret[domain]} and ${k}: they must be unique` ); } ret[domain] = k; } } } private init_strategies_endpoint(): void { // Return the configured and supported authentication strategies. this.router.get(`${AUTH_BASE}/strategies`, (req, res) => { if (req.query.v === "2") { this.strategies_v2(res); } else { this.strategies_v1(res); } }); } private async init_email_verification(): Promise { // email verification this.router.get(`${AUTH_BASE}/verify`, async (req, res) => { const { DOMAIN_URL } = require("@cocalc/util/theme"); const path = require("path").join(base_path, "app"); const url = `${DOMAIN_URL}${path}`; res.header("Content-Type", "text/html"); res.header("Cache-Control", "no-cache, no-store"); if ( !(req.query.token && req.query.email) || typeof req.query.email !== "string" || typeof req.query.token !== "string" ) { res.send( "ERROR: I need the email address and the corresponding token data" ); return; } const email = decodeURIComponent(req.query.email); // .toLowerCase() on purpose: some crazy MTAs transform everything to uppercase! const token = req.query.token.toLowerCase(); try { await cb2(this.database.verify_email_check_token, { email_address: email, token, }); res.send(email_verified_successfully(url)); } catch (err) { res.send(email_verification_problem(url, err)); } }); } private init_password_reset_token(): void { // reset password: user email link contains a token, which we store in a session cookie. // this prevents leaking that token to 3rd parties as a referrer // endpoint has to match with @cocalc/hub/password this.router.get(`${AUTH_BASE}/password_reset`, (req, res) => { if (typeof req.query.token !== "string") { res.send("ERROR: reset token must be set"); } else { const token = req.query.token.toLowerCase(); const cookies = new Cookies(req, res); // to match @cocalc/frontend/client/password-reset const name = encodeURIComponent(`${base_path}PWRESET`); const secure = req.protocol === "https"; cookies.set(name, token, { maxAge: ms("5 minutes"), secure: secure, overwrite: true, httpOnly: false, }); res.redirect("../app"); } }); } private get_extra_default_opts({ name, type, }: { type: PassportTypes; name: string; }) { switch (type) { case "saml": // see https://github.com/node-saml/passport-saml#config-parameter-details const cachedMS = ms("8 hours"); return { issuer: this.auth_url, signatureAlgorithm: "sha256", // better than default sha1 digestAlgorithm: "sha256", // better than default sha1 wantAssertionsSigned: true, acceptedClockSkewMs: ms("5 minutes"), // if "*:persistent" doesn't work, use *:emailAddress identifierFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", requestIdExpirationPeriodMs: cachedMS, validateInResponseTo: true, cacheProvider: getPassportCache(name, cachedMS), }; } } private get_extra_opts(name, conf: PassportStrategyDBConfig) { // "extra_opts" is passed to the passport.js "Strategy" constructor! // e.g. arbitrary fields like a tokenURL will be extracted here, and then passed to the constructor const extracted = _.omit(conf, [ "name", // deprecated "display", // deprecated "type", "icon", // deprecated "login_info", // already extracted, see login_info field above "clientID", "clientSecret", "userinfoURL", "public", // we don't need that info for initializing them "auth_opts", // we pass them as a separate parameter ]); return { ...this.get_extra_default_opts({ name, type: conf.type }), ...extracted, }; } // this maps additional strategy configurations to a list of StrategyConf objects // the overall goal is to support custom OAuth2 and LDAP endpoints, where additional // info is sent to the webapp client to properly present them. Google&co are "primary" configurations. // // here is one example what can be saved in the DB to make this work for a general OAuth2 // if this SSO is not public (e.g. uni campus, company specific, ...) mark it as {"public":false}! // // insert into passport_settings (strategy, conf, info ) VALUES ( '[unique, e.g. "wowtech"]', '{"type": "oauth2next", "clientID": "CoCalc_Client", "scope": ["email", "cocalc", "profile", ... depends on the config], "clientSecret": "[a password]", "authorizationURL": "https://domain.edu/.../oauth2/authorize", "userinfoURL" :"https://domain.edu/.../oauth2/userinfo", "tokenURL":"https://domain.edu/.../oauth2/...extras.../access_token", "login_info" : {"emails" :"emails[0].value"}}'::JSONB, {"display": "[user visible, e.g. "WOW Tech"]", "icon": "https://storage.googleapis.com/square.svg", "public": false}::JSONB); // // note, the login_info.emails string extracts from the profile object constructed by parse_openid_profile, // which is only triggered if there is such a "userinfoURL", which is OAuth2 specific. // other auth mechanisms might already provide the profile in passport.js's structure! private async init_extra_strategies(): Promise { if (this.passports == null) throw Error("strategies not initalized!"); const inits: Promise[] = []; for (const [name, strategy] of Object.entries(this.passports)) { if (PRIMARY_STRATEGIES.indexOf(name as any) >= 0) { continue; } if (strategy.conf.type == null) { throw new Error( `all "extra" strategies must define their type, in particular also "${name}"` ); } const type: PassportTypes = strategy.conf.type; // the constructor const PassportStrategyConstructor = getExtraStrategyConstructor(type); const config: StrategyConf = { name, type, PassportStrategyConstructor, login_info: { ...DEFAULT_LOGIN_INFO, ...strategy.conf.login_info }, userinfoURL: strategy.conf.userinfoURL, extra_opts: this.get_extra_opts(name, strategy.conf), update_on_login: strategy.info?.update_on_login ?? false, cookie_ttl_s: strategy.info?.cookie_ttl_s, // could be undefined, that's OK auth_opts: strategy.conf.auth_opts ?? {}, } as const; inits.push(this.initStrategy(config)); } await Promise.all(inits); } // this is the 2nd entry for the strategy, just a basic callback private getVerify(type: StrategyConf["type"]) { switch (type) { case "saml": return (profile, done) => { done(undefined, profile); }; case "azuread": return (_iss, _sub, profile, _accessToken, _refreshToken, done) => { if (!profile.oid) { return done(new Error("No oid found"), null); } done(undefined, profile); }; case "oidc": return (_issuer, profile, done) => { return done(undefined, profile); }; default: return (_accessToken, _refreshToken, params, profile, done) => { done(undefined, { params, profile }); }; } } private getStrategyInstance(args: StrategyInstanceOpts) { const { type, opts, userinfoURL, PassportStrategyConstructor } = args; const L1 = logger.extend("get_strategy_instance"); const L2 = L1.extend("userProfile").debug; const verify = this.getVerify(type); const strategy_instance = new PassportStrategyConstructor(opts, verify); // for OAuth2, set the userinfoURL to get the profile if (userinfoURL != null) { addUserProfileCallback({ strategy_instance, userinfoURL, L2, type }); } return strategy_instance; } private getHandleReturn({ Linit, name, type, update_on_login, cookie_ttl_s, login_info, }) { return async (req, res: express.Response) => { if (req.user == null) { throw Error("req.user == null -- that shouldn't happen"); } const Lret = Linit.extend(`${name}/return`).debug; // usually, we pick the "profile", but in some cases like SAML this is in "attributes". // finally, as a fallback, we just take the ".user" // technically, req.user should never be undefined, though. const profile = (req.user.profile != null ? req.user.profile : req.user.attributes != null ? req.user.attributes : req.user) as any as passport.Profile; if (type === "saml") { // the nameID is set via the conf.identifierFormat parameter – even if we set it to // persistent, we might still just get an email address, though Lret(`nameID format we actually got is ${req.user.nameIDFormat}`); profile.id = req.user.nameID; } Lret(`profile = ${safeJsonStringify(profile)}`); const login_opts: PassportLoginOpts = { passports: this.passports ?? {}, database: this.database, host: this.host, record_sign_in: sign_in.record_sign_in, id: profile.id, // ATTN: not all strategies have an ID → you have to derive the ID from the profile below via the "login_info" mapping (e.g. {id: "email"}) strategyName: name, profile, // will just get saved in database update_on_login, cookie_ttl_s, req, res, }; for (const k in login_info) { const v = login_info[k]; const param: string | string[] = typeof v == "function" ? // v is a LoginInfoDerivator v(profile) : // v is a string for dot-object dot.pick(v, profile); login_opts[k] = param; } const passportLogin = new PassportLogin(login_opts); try { await passportLogin.login(); } catch (err) { let err_msg = ""; // due to https://github.com/Microsoft/TypeScript/issues/13965 we have to check on name and can't use instanceof if (err.name === "PassportLoginError") { const signInUrl = path_join(base_path, "auth", "sign-in"); err_msg = `Problem signing in using '${name}:
${ err.message ?? `${err}` }
Sign-in again`; } else { const helpEmail = await passportLogin.getHelpEmail(); err_msg = `Error trying to login using '${name}' -- if this problem persists please contact ${helpEmail} -- ${err}
${err.stack}
`; } Lret(`sending error "${err_msg}"`); res.send(err_msg); } }; } // right now, we only set this for OAauth2 (SAML knows what to do on its own) // This does not encode any information for now. private setState(name, type: PassportTypes, auth_opts) { return async (_req, _res, next) => { if (isOAuth2(type)) { const oauthcache = getOauthCache(name); const state = uuidv4(); await oauthcache.saveAsync(state, `${Date.now()}`); auth_opts.state = state; logger.debug("session: " + auth_opts.state); } next(); }; } // corresponding check to the above. basically checks if the state data is still available. private checkState(name, type: PassportTypes) { return async (req, _res, next) => { if (isOAuth2(type)) { const oauthcache = getOauthCache(name); const state = req.query.state; const saved_state = await oauthcache.getAsync(state); if (saved_state == null) { throw Error(`Invalid state: ${state}`); } await oauthcache.removeAsync(state); } next(); }; } // a generalized strategy initizalier private async initStrategy(strategy_config: StrategyConf): Promise { const { name, // our "name" of the strategy, set in the DB type, // the "type", which is the key in the k PassportStrategyConstructor, extra_opts, auth_opts = {}, login_info, userinfoURL, cookie_ttl_s, update_on_login = false, } = strategy_config; const Linit = logger.extend("init_strategy"); const L = Linit.debug; L(`init_strategy ${name}`); if (this.passports == null) throw Error("strategies not initalized!"); if (name == null) { L(`strategy name is null -- aborting initialization`); return; } const confDB = this.passports[name]; if (confDB == null) { L(`no conf for strategy='${name}' in DB -- aborting initialization`); return; } // under the same name, we make it accessible const strategyUrl = `${AUTH_BASE}/${name}`; const returnUrl = `${strategyUrl}/return`; if (confDB.conf == null) { // This happened on *all* of my dev servers, etc. -- William L( `strategy='${name}' is not properly configured -- aborting initialization` ); return; } const opts = { clientID: confDB.conf.clientID, clientSecret: confDB.conf.clientSecret, callbackURL: returnUrl, ...extra_opts, } as const; // attn: this log line shows secrets // logger.debug(`opts = ${safeJsonStringify(opts)}`); const strategy_instance = this.getStrategyInstance({ type, opts, userinfoURL, PassportStrategyConstructor, }); // this ties the name (our name set in the DB) to the strategy instance passport.use(name, strategy_instance); this.router.get( strategyUrl, this.handle_get_api_key, this.setState(name, type, auth_opts), passport.authenticate(name, auth_opts) ); // this will hopefully do new PassportLogin().login() const handleReturn = this.getHandleReturn({ Linit, name, type, update_on_login, cookie_ttl_s, login_info, }); if (type === "saml") { this.router.post( returnUrl, // the body-parser package is deprecated, using express directly express.urlencoded({ extended: false }), express.json(), passport.authenticate(name), async (req, res) => { // block below: boilerplate-code to parse the response from the SAML provider – could become helpful some day! //const xmlResponse = req.body.SAMLResponse; //if (xmlResponse == null) { // throw new Error("SAML xmlResponse is null"); //} //const samlRes = new Saml2js(xmlResponse); //if (req.user == null) req.user = {}; //req.user["profile"] = samlRes.toObject(); await handleReturn(req, res); } ); } else if (isOAuth2(type)) { this.router.get( returnUrl, this.checkState(name, type), passport.authenticate(name), handleReturn ); } else { this.router.get(returnUrl, passport.authenticate(name), handleReturn); } L(`initialization of '${name}' at '${strategyUrl}' successful`); } } interface IsPasswordCorrect { database: PostgreSQL; password: string; password_hash?: string; account_id?: string; email_address?: string; allow_empty_password?: boolean; cb: (err?, correct?: boolean) => void; } // NOTE: simpler clean replacement for this is in packages/server/auth/is-password-correct.ts // // Password checking. opts.cb(undefined, true) if the // password is correct, opts.cb(error) on error (e.g., loading from // database), and opts.cb(undefined, false) if password is wrong. You must // specify exactly one of password_hash, account_id, or email_address. // In case you specify password_hash, in addition to calling the // callback (if specified), this function also returns true if the // password is correct, and false otherwise; it can do this because // there is no async IO when the password_hash is specified. export async function is_password_correct( opts: IsPasswordCorrect ): Promise { opts = defaults(opts, { database: required, password: required, password_hash: undefined, account_id: undefined, email_address: undefined, // If true and no password set in account, it matches anything. // this is only used when first changing the email address or password // in passport-only accounts. allow_empty_password: false, // cb(err, true or false) cb: required, }); if (opts.password_hash != null) { const r = verifyPassword(opts.password, opts.password_hash); opts.cb(undefined, r); } else if (opts.account_id != null || opts.email_address != null) { try { const account = await cb2(opts.database.get_account, { account_id: opts.account_id, email_address: opts.email_address, columns: ["password_hash"], }); if (opts.allow_empty_password && !account.password_hash) { if (opts.password && opts.account_id) { // Set opts.password as the password, since we're actually // setting the email address and password at the same time. opts.database.change_password({ account_id: opts.account_id, password_hash: passwordHash(opts.password), invalidate_remember_me: false, cb: (err) => opts.cb(err, true), }); } else { opts.cb(undefined, true); } } else { opts.cb( undefined, verifyPassword(opts.password, account.password_hash) ); } } catch (error) { opts.cb(error); } } else { opts.cb( "One of password_hash, account_id, or email_address must be specified." ); } } export async function verify_email_send_token(opts) { opts = defaults(opts, { database: required, account_id: required, only_verify: false, cb: required, }); try { const { token, email_address } = await cb2( opts.database.verify_email_create_token, { account_id: opts.account_id, } ); const settings = await cb2(opts.database.get_server_settings_cached); await cb2(welcome_email, { to: email_address, token, only_verify: opts.only_verify, settings, }); opts.cb(); } catch (err) { opts.cb(err); } }