src/access-login.ts
181 lines and 9 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| clearStateCookie | 33 to 35 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| accessSaas | 44 to 50 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| refusal | 55 to 57 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| s256 | 59 to 61 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| startAccessLogin | 74 to 109 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| completeAccessLogin | 117 to 196 | 20 | 90% | WatchCRAP 20.5 | No data: mutation testing has not run on this repo yet |
| fail | 125 to 125 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:176> | 176 to 176 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:179> | 179 to 179 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/access-jwt.ts, src/auth.ts, src/encoding.ts, src/env.ts. Imported by src/portal-auth.ts, src/routes.ts.
Source
import { verifyIdToken, type KeysFetcher } from "./access-jwt";
import { getCookie, isAdminEmail, sha256Hex } from "./auth";
import { safeEqual } from "@dustinedwards/site-runtime/compare";
import { b64urlFromBytes, bytesToHex } from "./encoding";
import type { Env } from "./env";
// The sign-in round trip with Cloudflare Access for SaaS (OIDC) as the upstream
// identity (capsid/research/design-capsid-access-login.md, decided 2026-09-27), shared
// by the MCP authorization flow (src/routes.ts) and Capsid Portal's login
// (src/portal-auth.ts). What differs between them is passed in as a LoginFlow; what
// each stores and does with the admitted email stays in the caller. A state token is
// stored in OAUTH_KV for ten minutes, bound to the browser by a cookie holding a random
// value of its own whose digest the stored state keeps, and used once. PKCE and an OIDC nonce are kept in the stored state, and the
// ID token is verified in src/access-jwt.ts. The person is the email in the token,
// admitted only when it equals ADMIN_EMAIL or a CO_OWNER_EMAILS entry exactly.
//
// Every endpoint derives from the team domain and the client id, as the SaaS app's
// discovery document states them (measured 2026-09-27).
// How long a started login stays usable: the KV state entry and the state cookie.
export const STATE_TTL_SECONDS = 600;
export interface LoginFlow {
// The path Access redirects back to, on this Worker's origin.
callbackPath: string;
stateCookie: string;
// The state cookie's Path attribute.
cookiePath: string;
kvPrefix: string;
// Appended to each state refusal, telling the user where to start again.
restartHint: string;
}
export function clearStateCookie(flow: LoginFlow): string {
return `${flow.stateCookie}=; HttpOnly; Secure; SameSite=Lax; Path=${flow.cookiePath}; Max-Age=0`;
}
interface AccessSaas {
clientId: string;
clientSecret: string;
issuer: string;
}
// The SaaS app's settings, or null while any is unset: the login is then closed.
function accessSaas(env: Env): AccessSaas | null {
const team = env.ACCESS_TEAM_DOMAIN?.trim().replace(/\/+$/, "");
const clientId = env.ACCESS_SAAS_CLIENT_ID?.trim();
const clientSecret = env.ACCESS_SAAS_CLIENT_SECRET?.trim();
if (!team || !clientId || !clientSecret) return null;
return { clientId, clientSecret, issuer: `${team}/cdn-cgi/access/sso/oidc/${encodeURIComponent(clientId)}` };
}
const TOKEN_EXCHANGE_TIMEOUT_MS = 15_000;
const UNCONFIGURED = "the sign-in is not configured (ACCESS_TEAM_DOMAIN, ACCESS_SAAS_CLIENT_ID, ACCESS_SAAS_CLIENT_SECRET), so it is closed";
function refusal(message: string, status: number): Response {
return new Response(message, { status, headers: { "Content-Type": "text/plain;charset=utf-8" } });
}
async function s256(verifier: string): Promise<string> {
return b64urlFromBytes(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
}
// What the KV state holds: the caller's own value, this sign-in's PKCE verifier and
// nonce, which never leave the server, and the digest of the browser binding the state
// cookie carries.
interface StoredSignIn {
stored: string;
verifier: string;
nonce: string;
binding: string;
}
// Stores `stored` against a fresh state token and redirects to Access.
export async function startAccessLogin(
request: Request,
env: Env,
flow: LoginFlow,
stored: string,
extraCookies: string[] = []
): Promise<Response> {
const saas = accessSaas(env);
if (!saas) return refusal(UNCONFIGURED, 503);
const stateToken = crypto.randomUUID();
// The browser binding is random and unrelated to the state token. A cookie derived
// from the token could be computed by anyone holding the callback URL, which carries
// the token, and would bind nothing (Grok audit 2026-10, S6).
const binding = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
const signIn: StoredSignIn = {
stored,
verifier: b64urlFromBytes(crypto.getRandomValues(new Uint8Array(32))),
nonce: crypto.randomUUID(),
binding: await sha256Hex(binding),
};
await env.OAUTH_KV.put(`${flow.kvPrefix}${stateToken}`, JSON.stringify(signIn), { expirationTtl: STATE_TTL_SECONDS });
const target = new URL(`${saas.issuer}/authorization`);
target.searchParams.set("client_id", saas.clientId);
target.searchParams.set("redirect_uri", `${new URL(request.url).origin}${flow.callbackPath}`);
target.searchParams.set("response_type", "code");
target.searchParams.set("scope", "openid email profile");
target.searchParams.set("state", stateToken);
target.searchParams.set("nonce", signIn.nonce);
target.searchParams.set("code_challenge", await s256(signIn.verifier));
target.searchParams.set("code_challenge_method", "S256");
const headers = new Headers({ Location: target.href });
// The state cookie carries the binding, which neither the URL nor the stored state holds.
headers.append("Set-Cookie", `${flow.stateCookie}=${binding}; HttpOnly; Secure; SameSite=Lax; Path=${flow.cookiePath}; Max-Age=${STATE_TTL_SECONDS}`);
for (const cookie of extraCookies) headers.append("Set-Cookie", cookie);
return new Response(null, { status: 302, headers });
}
export type AccessLoginResult<T> = { ok: true; email: string; name?: string; state: T } | { ok: false; response: Response };
// Verifies the callback's state against the cookie and KV, exchanges the code with
// PKCE, verifies the ID token and runs the admin check. `parse` turns the caller's
// stored value back into what it stored; if it throws, the state is deleted and the
// callback refused before anything reaches Access.
export async function completeAccessLogin<T>(
request: Request,
env: Env,
flow: LoginFlow,
parse: (stored: string) => T,
now: Date = new Date(),
fetchKeys?: KeysFetcher
): Promise<AccessLoginResult<T>> {
const fail = (message: string, status: number): AccessLoginResult<T> => ({ ok: false, response: refusal(message, status) });
const saas = accessSaas(env);
if (!saas) return fail(UNCONFIGURED, 503);
const url = new URL(request.url);
// Access reports a refused sign-in as error=... on the callback.
const upstreamError = url.searchParams.get("error");
if (upstreamError) return fail(`the sign-in was refused by Access: ${upstreamError.slice(0, 80)}. ${flow.restartHint}`, 403);
const code = url.searchParams.get("code");
const stateToken = url.searchParams.get("state");
if (!code || !stateToken) return fail("missing code or state", 400);
const notThisBrowser = `state validation failed: this browser did not start the flow. ${flow.restartHint}`;
const stateCookie = getCookie(request, flow.stateCookie);
if (!stateCookie) return fail(notThisBrowser, 403);
const stateKey = `${flow.kvPrefix}${stateToken}`;
const raw = await env.OAUTH_KV.get(stateKey);
if (!raw) return fail(`state expired or already used. ${flow.restartHint}`, 403);
let signIn: StoredSignIn;
try {
signIn = JSON.parse(raw) as StoredSignIn;
if (typeof signIn.verifier !== "string" || typeof signIn.nonce !== "string" || typeof signIn.stored !== "string" || typeof signIn.binding !== "string") {
throw new Error("shape");
}
} catch {
await env.OAUTH_KV.delete(stateKey);
return fail(`stored authorization state is unreadable. ${flow.restartHint}`, 403);
}
// A cookie that does not match leaves the state in place, so a stranger holding the
// callback URL cannot spend the browser's sign-in.
if (!await safeEqual(await sha256Hex(stateCookie), signIn.binding)) return fail(notThisBrowser, 403);
let state: T;
try {
state = parse(signIn.stored);
} catch {
await env.OAUTH_KV.delete(stateKey);
return fail(`stored authorization state is unreadable. ${flow.restartHint}`, 403);
}
// Bounded, so a hung token endpoint does not hold the sign-in (Grok audit 2026-10-21, CA-10).
const tokenResp = await fetch(`${saas.issuer}/token`, {
method: "POST",
signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS),
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: `${url.origin}${flow.callbackPath}`,
client_id: saas.clientId,
client_secret: saas.clientSecret,
code_verifier: signIn.verifier,
}),
}).catch(() => null);
if (!tokenResp) return fail("the Access token exchange did not answer in time", 502);
if (!tokenResp.ok) return fail(`the Access token exchange failed (${tokenResp.status})`, 502);
const tokens = (await tokenResp.json().catch(() => ({}))) as { id_token?: unknown };
if (typeof tokens.id_token !== "string") return fail("the Access token exchange returned no ID token", 502);
// The state is consumed after the exchange, so a transient Access error does not burn
// it. Replay is bounded by Access, which honours a code once.
await env.OAUTH_KV.delete(stateKey);
const verdict = await verifyIdToken(
tokens.id_token,
{ issuer: saas.issuer, clientId: saas.clientId, jwksUrl: `${saas.issuer}/jwks`, nonce: signIn.nonce },
now,
fetchKeys
);
if (!verdict.ok) return fail(`the sign-in could not be verified: ${verdict.reason}. ${flow.restartHint}`, 403);
if (!await isAdminEmail(env, verdict.email)) {
return fail("access denied: this email is not an administrator of capsid (ADMIN_EMAIL or CO_OWNER_EMAILS)", 403);
}
return verdict.name ? { ok: true, email: verdict.email, name: verdict.name, state } : { ok: true, email: verdict.email, state };
}