UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

244 lines • 11 kB
/** * hosting/signin/redirectRoutes — the sign-in door's redirect half: * `GET /auth/login` and `GET /auth/callback` (design §5.2). PENDING * INDEPENDENT REVIEW before a company install. * * `GET /auth/login` * Check `returnTo` (§5.5). Make `state`, `nonce` and (unless PKCE is off) a * PKCE verifier. SEAL them, with `returnTo` and the time, into this * attempt's transaction cookie. Nothing is written on the server. Redirect * to the identity provider. * * `GET /auth/callback`, in this order: * 1. Open the transaction cookie named by `state` — sealed by this server, * for this attempt, less than 10 minutes old — and compare `state`. * 2. Clear the transaction cookie, WHATEVER the outcome: `state` is single use. * 3. The strategy exchanges the code (client authentication + the PKCE * verifier), checks the ID token (`iss`, `aud`, `nonce`, `exp`) and runs * its OWN `verify` on the access token — the person test included. * 4. End any sign-in the browser already carries; create the new one. * 5. 303 to `returnTo`, with `Referrer-Policy: no-referrer` so the code in * this URL does not leak onward. * A failure is a 303 to `/?signin_error=<code>`: a fixed reason code, never * the identity provider's text. * * Bounded per browser (review idI57 S-2): a login keeps at most the newest * {@link KEPT_PENDING} OTHER pending transaction cookies and expires the rest, * and a sealed transaction is at most {@link MAX_SEALED_BYTES} — so no page * can stack enough cookies on this origin to make every request a 431. * * A failure that is the deployment's, not the browser's (the IdP or the store * unreachable, the library missing) is logged by CLASS, once per change. * * Two `GET` routes change state, deliberately: the login sets a cookie and the * callback creates a sign-in. Both are safe — the callback needs the sealed * cookie issued to THIS browser, a matching `state`, and a single-use code * bound to the PKCE verifier and nonce. */ import { randomBytes, timingSafeEqual } from 'node:crypto'; import { safeReturnTo } from './returnTo.js'; import { seal, unseal } from './seal.js'; import { RedirectSignInError, } from './types.js'; const TRANSACTION_MS = 600_000; const ATTEMPT = /^[A-Za-z0-9_-]{16}$/; /** Pending transactions a login keeps besides its own — so one browser holds at most three. */ export const KEPT_PENDING = 2; /** The largest sealed transaction, in bytes; a larger one is re-sealed with `returnTo` = `/`. */ export const MAX_SEALED_BYTES = 1200; export function redirectRoutesFor(ctx) { const redirectUri = `${ctx.publicUrl.origin}${ctx.prefix}/callback`; const go = (res, status, location, cookies) => { if (res.headersSent) return; res.writeHead(status, { location, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': "frame-ancestors 'none'", ...(cookies.length > 0 && { 'set-cookie': cookies }), }); res.end(); }; const failed = (res, reason, cookies) => go(res, 303, `/?signin_error=${encodeURIComponent(reason)}`, cookies); // The deployment's failures, logged by class once per change (review idI57 // S-1): an operator sees WHY every sign-in fails, and a failing IdP does // not flood the log. Never the IdP's text — only a class name. let lastLogged; const note = (where, err) => { const kind = err instanceof RedirectSignInError ? err.reason : err instanceof Error && typeof err.name === 'string' ? err.name : 'unknown'; const line = `${where}: ${kind}`; if (line === lastLogged) return; lastLogged = line; ctx.warn(`[hosting] sign-in door: browser sign-in failed at ${line}`); }; const recovered = () => { if (lastLogged === undefined) return; lastLogged = undefined; ctx.warn('[hosting] sign-in door: browser sign-in is working again'); }; return { async login(req, res) { const url = new URL(req.url ?? '/', ctx.publicUrl.origin); const returnTo = safeReturnTo(url.searchParams.get('returnTo'), ctx.publicUrl, ctx.prefix); const attempt = randomBytes(12).toString('base64url'); const secrets = { state: `${attempt}.${randomBytes(24).toString('base64url')}`, nonce: randomBytes(24).toString('base64url'), ...(ctx.strategy.pkce && { codeVerifier: randomBytes(32).toString('base64url') }), }; let location; try { location = await ctx.strategy.authorizationUrl(secrets, redirectUri); } catch (err) { note('the login', err); return failed(res, 'unavailable', []); } recovered(); const transaction = { s: secrets.state, n: secrets.nonce, ...(secrets.codeVerifier !== undefined && { v: secrets.codeVerifier }), r: returnTo, t: ctx.now(), }; const name = ctx.cookie.txName(attempt); let sealed = seal(ctx.sealKey, name, transaction); if (sealed.length > MAX_SEALED_BYTES) sealed = seal(ctx.sealKey, name, { ...transaction, r: '/' }); const stale = olderPending(ctx, req).map((old) => ctx.cookie.expireTx(old)); return go(res, 302, location, [...stale, ctx.cookie.issueTx(attempt, sealed)]); }, async callback(req, res) { const url = new URL(req.url ?? '/', ctx.publicUrl.origin); const state = url.searchParams.get('state') ?? ''; const attempt = state.split('.')[0] ?? ''; if (!ATTEMPT.test(attempt)) { ctx.audit(req, 'refused', 'state-mismatch'); return failed(res, 'state', []); } // Cleared whatever happens next: a `state` is single use. const clear = [ctx.cookie.expireTx(attempt)]; const transaction = openTransaction(ctx, req, attempt); if (transaction === undefined || !sameText(transaction.s, state)) { ctx.audit(req, 'refused', 'state-mismatch'); return failed(res, 'state', clear); } const secrets = { state: transaction.s, nonce: transaction.n, ...(transaction.v !== undefined && { codeVerifier: transaction.v }), }; let accepted; try { accepted = await ctx.strategy.complete(url, secrets, redirectUri); } catch (err) { const reason = err instanceof RedirectSignInError ? err.reason : 'exchange-failed'; // A refusal is the browser's business; an IdP that cannot be reached // or a strategy that threw something unexpected is the operator's. if (reason === 'unavailable' || !(err instanceof RedirectSignInError)) { note('the callback', err); } if (reason === 'unavailable') ctx.audit(req, 'unavailable', 'idp-unavailable'); else ctx.audit(req, 'refused', reason); return failed(res, reason, clear); } let started; try { // Rotation: a sign-in the browser already carries ends first. await ctx.endPresent(req); started = await ctx.startSignIn(accepted, ctx.strategy.strategy); recovered(); } catch (err) { // A store that is full or down: the same redirect shape as every // other failure, and the transaction cookie still cleared (N-4). note('the callback', err); ctx.audit(req, 'unavailable', 'store-failed'); return failed(res, 'unavailable', clear); } ctx.audit(req, 'signed-in', 'accepted', accepted.identity.userId); return go(res, 303, transaction.r, [...clear, started.setCookie]); }, async logoutNext() { try { return (await ctx.strategy.endSessionUrl?.(`${ctx.publicUrl.origin}/`)) ?? '/'; } catch { return '/'; } }, }; } /** The attempt's transaction, if this server sealed it for this attempt within 10 minutes. */ function openTransaction(ctx, req, attempt) { const name = ctx.cookie.txName(attempt); const value = cookieValue(req.headers.cookie, name); if (value === undefined) return undefined; const opened = unseal(ctx.sealKey, name, value); if (opened === undefined || opened === null || typeof opened.s !== 'string' || typeof opened.n !== 'string' || typeof opened.r !== 'string' || typeof opened.t !== 'number' || (opened.v !== undefined && typeof opened.v !== 'string')) { return undefined; } const age = ctx.now() - opened.t; if (age < 0 || age > TRANSACTION_MS) return undefined; return opened; } /** * The attempts of the OTHER pending transactions this request carries, beyond * the newest {@link KEPT_PENDING} — the ones this login expires. A cookie this * server cannot open (or that is past its 10 minutes) counts as the oldest. */ function olderPending(ctx, req) { const prefix = ctx.cookie.txName(''); const attempts = new Set(); for (const part of (req.headers.cookie ?? '').split(';')) { const name = part.trim().split('=')[0] ?? ''; if (!name.startsWith(prefix)) continue; const attempt = name.slice(prefix.length); if (ATTEMPT.test(attempt)) attempts.add(attempt); } if (attempts.size <= KEPT_PENDING) return []; const aged = [...attempts].map((attempt) => ({ attempt, t: openTransaction(ctx, req, attempt)?.t ?? -Infinity, })); aged.sort((a, b) => b.t - a.t); return aged.slice(KEPT_PENDING).map((a) => a.attempt); } /** The one value of a named cookie; two of the same name name nothing. */ function cookieValue(header, name) { if (header === undefined) return undefined; const found = header .split(';') .map((part) => part.trim()) .filter((part) => part.startsWith(`${name}=`)) .map((part) => part.slice(name.length + 1)); return found.length === 1 ? found[0] : undefined; } function sameText(a, b) { const x = Buffer.from(a, 'utf8'); const y = Buffer.from(b, 'utf8'); return x.length === y.length && timingSafeEqual(x, y); } //# sourceMappingURL=redirectRoutes.js.map