// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying // party. Used only by the human listener under --sso; startServer refuses it on the tester listener. // Every URL handed to a provider is built from the configured publicOrigin, never from a request's // Host. A refusal logs one fixed code and the provider id, never an exception, its message or its // cause: oauth4webapi's errors carry token bodies and claims there. import { createHash, randomBytes } from 'node:crypto'; import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, relative, isAbsolute, resolve } from 'node:path'; import { checkConsole } from './sso-config.mjs'; // oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a // Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock // installs nothing). A real copy installs it from the root lock. let oauth; export const SESSION_COOKIE = '__Host-mosaic_session'; export const LOGIN_COOKIE = '__Host-mosaic_login'; const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax'; const LOGIN_MAX_AGE = 600; const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000; const MAX_SESSIONS = 512, MAX_LOGINS = 128, MAX_ACCOUNT_SESSIONS = 8; const FETCH_TIMEOUT = 10000; const RETRY_CAP = 300000; const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'account-revoked', 'discovery-failed', 'jwks-failed']); const sha256 = value => createHash('sha256').update(value).digest('hex'); const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`); const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); }; // The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso // start calls this. A refusal names the provider and the check, never the path's contents. export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) { const where = `console.sso provider ${provider.id} client secret slot`; const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot); let st; try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); } if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`); let fd, real, text; try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); st = fstatSync(fd); if (!st.isFile()) throw new Error(`${where} must be a regular file`); if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`); if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`); real = realpathSync(file); for (const root of [repo, checkout, copies].filter(Boolean)) { let base; try { base = realpathSync(root); } catch { base = resolve(root); } if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`); } text = readFileSync(fd, 'utf8'); } catch (err) { throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`); } finally { if (fd !== undefined) closeSync(fd); } const secret = text.endsWith('\n') ? text.slice(0, -1) : text; if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`); return secret; } export function cookies(req, name) { const found = []; for (const part of (req.headers.cookie ?? '').split(';')) { const at = part.indexOf('='); if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim()); } return found; } // The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use, // key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet. function usableKey(jwk, alg) { const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)]; if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false; if (jwk.alg !== undefined && jwk.alg !== alg) return false; if (jwk.use !== undefined && jwk.use !== 'sig') return false; if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false; const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg]; if (crv && jwk.crv !== crv) return false; if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false; return true; } class Unreachable extends Error {} class Refused extends Error {} const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } }; // One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200, // a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4. async function discover(p, fetchOptions) { let response, text; const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) }); try { response = await oauth.discoveryRequest(new URL(p.issuer), options()); if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error(); text = await response.text(); JSON.parse(text); } catch { throw new Unreachable(); } let as; try { as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } })); } catch { throw new Refused(); } if (as.issuer !== p.issuer) throw new Refused(); if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused(); if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused(); if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused(); if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused(); let jwks; try { const fetcher = fetchOptions[oauth.customFetch] ?? fetch; const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) }); if (res.status !== 200) throw new Error(); jwks = JSON.parse(await res.text()); } catch { throw new Unreachable(); } if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused(); const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg))); if (!usable.length) throw new Refused(); return as; } // `config` is console.sso, checked again here; `secrets` maps provider id to its client secret // (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate. // `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes. // `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the // owner's signs in as the access-file user with a live grant, and each request checks it again. export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null, accounts = null } = {}) { checkConsole({ sso: config }); oauth ??= await import('oauth4webapi'); const publicOrigin = config.publicOrigin; const publicHost = new URL(publicOrigin).host; const redirectUri = `${publicOrigin}/auth/callback`; const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {}; const sessions = new Map(), logins = new Map(); const timers = new Set(); let closed = false; const refuse = (code, provider, extra = '') => { if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`); log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`); }; const providers = new Map(config.providers.map(p => { if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`); return [p.id, { config: p, state: 'unreachable', as: null, jwks: {}, client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] }, auth: oauth.ClientSecretBasic(secrets.get(p.id)), subjects: new Map(p.subjects.map(s => [s.sub, s.name])), }]; })); async function attempt(entry, atStart) { try { entry.as = await discover(entry.config, fetchOptions); entry.jwks = {}; entry.state = 'ready'; } catch (err) { if (err instanceof Refused) { entry.state = 'refused'; refuse('discovery-failed', entry.config.id); if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`); return; } entry.state = 'unreachable'; refuse('discovery-failed', entry.config.id); throw err; } } function retry(entry, delay) { if (closed) return; const timer = setTimeout(async () => { timers.delete(timer); try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); } }, delay); timer.unref(); timers.add(timer); } for (const entry of providers.values()) { try { await attempt(entry, true); } catch (err) { if (!(err instanceof Unreachable)) throw err; retry(entry, retryFirst); } } const page = (res, status, title, html, headers = {}) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers }); res.end(`${escape(title)}

${escape(title)}

${html}
\n`); }; const redirect = (res, location, setCookie = []) => { res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) }); res.end(); }; const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '

Sign in again

', setCookie.length ? { 'set-cookie': setCookie } : {}); const expire = name => `${name}=; ${ATTRS}; Max-Age=0`; const remember = (map, key, value, cap) => { while (map.size >= cap) map.delete(map.keys().next().value); map.set(key, value); }; // B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The // Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`, // which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403). function returnURL(ret) { const home = `${publicOrigin}/`; try { if (typeof ret !== 'string' || !ret) return home; const u = new URL(ret, publicOrigin); if (u.origin !== publicOrigin) return home; const out = new URL(`${publicOrigin}${u.pathname}${u.search}`); return out.origin === publicOrigin ? out.href : home; } catch { return home; } } async function start(res, entry, ret) { const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier(); const challenge = await oauth.calculatePKCECodeChallenge(verifier); const id = randomBytes(32).toString('base64url'); remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS); const url = new URL(entry.as.authorization_endpoint); for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v); return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]); } function login(res, query) { const asked = query.get('provider'); const ret = query.get('return'); if (asked === null && providers.size > 1) { const items = [...providers.values()].map(e => `
  • ${escape(e.config.id)}
  • `).join(''); return page(res, 200, 'Sign in', ``); } const entry = asked === null ? [...providers.values()][0] : providers.get(asked); if (!entry) return page(res, 404, 'Unknown sign-in provider', '

    Sign in

    '); if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '

    The sign-in provider can\'t be reached. Try again shortly.

    '); return start(res, entry, ret); } // Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page. async function callback(res, query, req) { const clear = [expire(LOGIN_COOKIE)]; const ids = cookies(req, LOGIN_COOKIE); const key = ids.length === 1 ? sha256(ids[0]) : null; const record = key === null ? undefined : logins.get(key); if (key !== null) logins.delete(key); // The provider comes from the record, never from the query. const entry = record && providers.get(record.provider); if (!record || record.expires <= Date.now() || !entry) { refuse('login-record-missing', record?.provider ?? 'none'); return failed(res, clear); } const pid = entry.config.id; if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); } let params; try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) { refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid); return failed(res, clear); } const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) }); let response; try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch { refuse('grant-failed', pid); return failed(res, clear); } let result, claims; try { result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true }); claims = oauth.getValidatedIdTokenClaims(result); } catch (err) { refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid); return failed(res, clear); } // Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same // Response object. Its throw ends the callback. try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) { const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code); refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid); return failed(res, clear); } // Step 6: our own checks on top of the library's. let header; try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; } if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); } if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); } // Account authority and the legacy root grant are independent. A configured // owner subject alone is never an account and cannot bypass a refused store. let account = null; try { account = accounts?.load().users.find(u => u.state === 'active' && u.subjects.some(s => s.provider === pid && s.sub === claims.sub)) ?? null; } catch { /* fail closed for accounts */ } const user = typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null; const rootOwner = account?.instanceRole === 'operator' && entry.subjects.has(claims.sub); if (!account && user === null) { refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`); return failed(res, clear); } const name = account?.name ?? user; const id = randomBytes(32).toString('base64url'), now = Date.now(); if (account) { const own = [...sessions].filter(([, s]) => s.account === account.id); while (own.length >= MAX_ACCOUNT_SESSIONS) sessions.delete(own.shift()[0]); } remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(account ? { account: account.id, instanceRole: account.instanceRole, authGen: account.authGen } : {}), rootOwner, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS); return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]); } // The session for this request, or null. `touch` is true only for a page load or a write (Q-D). function session(req, { touch = false } = {}) { const ids = cookies(req, SESSION_COOKIE); if (ids.length !== 1) return null; const key = sha256(ids[0]); const s = sessions.get(key); if (!s) return null; const now = Date.now(); if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; } if (s.account !== undefined) { let account; try { account = accounts?.load().users.find(u => u.id === s.account); } catch { /* refuse */ } if (!account || account.state !== 'active' || account.authGen !== s.authGen || account.instanceRole !== s.instanceRole || !account.subjects.some(x => x.provider === s.provider && x.sub === s.sub)) { refuse('account-revoked', s.provider); // Never reacquire account authority on this cookie, even after re-enable. delete s.account; delete s.instanceRole; delete s.authGen; s.rootOwner = false; } } if (s.user !== undefined && !users?.check(s)) { refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`); delete s.user; } if (s.account === undefined && s.user === undefined) { sessions.delete(key); return null; } if (touch) s.seen = now; return s; } function logout(req, res) { const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') { return page(res, 403, 'Sign-out refused', '

    Sign out from the Console page.

    '); } const ids = cookies(req, SESSION_COOKIE); const key = ids.length === 1 ? sha256(ids[0]) : null; const s = key === null ? undefined : sessions.get(key); if (key !== null) sessions.delete(key); const entry = s && providers.get(s.provider); let location = `${publicOrigin}/auth/signed-out`; if (entry?.as?.end_session_endpoint) { const url = new URL(entry.as.end_session_endpoint); url.searchParams.set('id_token_hint', s.idToken); url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`); url.searchParams.set('client_id', entry.config.clientId); location = url.href; } return redirect(res, location, [expire(SESSION_COOKIE)]); } // `/auth/*` on the human listener; the caller has already checked the Host. async function handle(req, res, path, search) { const query = new URLSearchParams(search); if (path === '/auth/logout') { if (req.method !== 'POST') return page(res, 405, 'Method not allowed', ''); return logout(req, res); } if (req.method !== 'GET') return page(res, 405, 'Method not allowed', ''); if (path === '/auth/login') return login(res, query); if (path === '/auth/callback') return callback(res, query, req); if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '

    Sign in

    '); return page(res, 404, 'Not found', ''); } return { mode: 'accounts', publicOrigin, publicHost, sessions, logins, handle, session, returnURL, chatGrants: session => users?.chatGrants?.(session) ?? [], health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])), formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))], summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })), close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); }, }; }