Skip to the content

Capsid menu

src/access-login.ts

181 lines and 9 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of access-login.ts
FunctionLinesComplexityCoveredCRAPMutation
clearStateCookie33 to 351100%OkCRAP 1No data: mutation testing has not run on this repo yet
accessSaas44 to 504100%OkCRAP 4No data: mutation testing has not run on this repo yet
refusal55 to 571100%OkCRAP 1No data: mutation testing has not run on this repo yet
s25659 to 611100%OkCRAP 1No data: mutation testing has not run on this repo yet
startAccessLogin74 to 1093100%OkCRAP 3No data: mutation testing has not run on this repo yet
completeAccessLogin117 to 1962090%WatchCRAP 20.5No data: mutation testing has not run on this repo yet
fail125 to 1251100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:176>176 to 1761100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:179>179 to 1791100%OkCRAP 1No 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 };
}