agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
248 lines • 11.4 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.redirectRoutesFor = exports.MAX_SEALED_BYTES = exports.KEPT_PENDING = void 0;
const node_crypto_1 = require("node:crypto");
const returnTo_js_1 = require("./returnTo.js");
const seal_js_1 = require("./seal.js");
const types_js_1 = require("./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. */
exports.KEPT_PENDING = 2;
/** The largest sealed transaction, in bytes; a larger one is re-sealed with `returnTo` = `/`. */
exports.MAX_SEALED_BYTES = 1200;
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 types_js_1.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 = (0, returnTo_js_1.safeReturnTo)(url.searchParams.get('returnTo'), ctx.publicUrl, ctx.prefix);
const attempt = (0, node_crypto_1.randomBytes)(12).toString('base64url');
const secrets = {
state: `${attempt}.${(0, node_crypto_1.randomBytes)(24).toString('base64url')}`,
nonce: (0, node_crypto_1.randomBytes)(24).toString('base64url'),
...(ctx.strategy.pkce && { codeVerifier: (0, node_crypto_1.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 = (0, seal_js_1.seal)(ctx.sealKey, name, transaction);
if (sealed.length > exports.MAX_SEALED_BYTES)
sealed = (0, seal_js_1.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 types_js_1.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 types_js_1.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 '/';
}
},
};
}
exports.redirectRoutesFor = redirectRoutesFor;
/** 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 = (0, seal_js_1.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 <= exports.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(exports.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 && (0, node_crypto_1.timingSafeEqual)(x, y);
}
//# sourceMappingURL=redirectRoutes.js.map