/** * Phase 09 Task #82 — PKCE / state / nonce challenge persistence. * * Stores the tuple { state, nonce, codeVerifier, createdAt } in an * HMAC-signed HttpOnly cookie scoped to /auth/admin. The cookie is * single-use: `consumeAdminSsoChallenge()` returns the payload and emits a * `Set-Cookie` header that clears it. * * We sign with HMAC-SHA256 over the JSON payload using * `CRYPTO_SECRET || SESSION_SECRET` (mirroring the magic-link convention), * and we encode payload+signature with URL-safe base64. The payload itself * is NOT encrypted — none of the values are user-secret per spec, and * encryption would just hide bugs. * * TTL is 10 minutes which is generous for Authentik UI redirects but short * enough that stolen cookies have a tiny replay window. The TTL is checked * server-side (cookie Max-Age is best-effort; clients lie). */ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { loadRequiredWebConfig } from "@toolctrl/config"; import { generators } from "openid-client"; export const ADMIN_SSO_CHALLENGE_COOKIE = "admin_sso_challenge"; export const ADMIN_SSO_CHALLENGE_TTL_MS = 10 * 60 * 1000; const COOKIE_PATH = "/auth/admin"; const PAYLOAD_VERSION = "v1"; export type AdminSsoChallengePayload = { readonly state: string; readonly nonce: string; readonly codeVerifier: string; readonly codeChallenge: string; /** Epoch milliseconds. */ readonly createdAt: number; /** Optional safe `returnTo` path captured at /auth/admin/login. */ readonly returnTo?: string; }; export type ConsumeResult = | { readonly ok: true; readonly payload: AdminSsoChallengePayload; readonly clearCookie: string } | { readonly ok: false; readonly reason: "missing" | "malformed" | "bad-signature" | "expired" }; export type CreateOptions = { readonly returnTo?: string; /** Injectable clock for tests. */ readonly now?: () => number; /** Override secret (tests). */ readonly secret?: string; }; function getSigningSecret(override?: string): string { if (override) return override; const { cryptoSecret, sessionSecret } = loadRequiredWebConfig(); const secret = cryptoSecret || sessionSecret; if (!secret) { throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for admin-sso challenge cookies."); } return secret; } function base64UrlEncode(buf: Buffer | string): string { const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf; return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function base64UrlDecode(value: string): Buffer { const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4)); const standard = value.replace(/-/g, "+").replace(/_/g, "/") + pad; return Buffer.from(standard, "base64"); } function sign(payload: string, secret: string): string { return base64UrlEncode(createHmac("sha256", secret).update(payload).digest()); } function verifySignature(payload: string, signature: string, secret: string): boolean { const expected = sign(payload, secret); const a = Buffer.from(signature); const b = Buffer.from(expected); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } /** * Generate a fresh challenge tuple. Caller is responsible for setting * `Set-Cookie` from `cookie` on the redirect response. */ export function createAdminSsoChallenge(options: CreateOptions = {}): { payload: AdminSsoChallengePayload; cookie: string } { const codeVerifier = generators.codeVerifier(); const codeChallenge = generators.codeChallenge(codeVerifier); const payload: AdminSsoChallengePayload = { state: randomBytes(32).toString("hex"), nonce: randomBytes(32).toString("hex"), codeVerifier, codeChallenge, createdAt: options.now ? options.now() : Date.now(), returnTo: options.returnTo, }; const cookie = serializeAdminSsoChallengeCookie(payload, options.secret); return { payload, cookie }; } export function serializeAdminSsoChallengeCookie(payload: AdminSsoChallengePayload, secretOverride?: string): string { const secret = getSigningSecret(secretOverride); const body = base64UrlEncode(JSON.stringify({ v: PAYLOAD_VERSION, p: payload })); const sig = sign(body, secret); const value = `${body}.${sig}`; const maxAgeSeconds = Math.ceil(ADMIN_SSO_CHALLENGE_TTL_MS / 1000); const attributes = [ `${ADMIN_SSO_CHALLENGE_COOKIE}=${value}`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", `Max-Age=${maxAgeSeconds}`, ]; if (process.env.NODE_ENV !== "test") { attributes.push("Secure"); } return attributes.join("; "); } /** * Returns a `Set-Cookie` header that clears the challenge cookie. */ export function clearAdminSsoChallengeCookie(): string { const attributes = [ `${ADMIN_SSO_CHALLENGE_COOKIE}=`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", "Max-Age=0", ]; if (process.env.NODE_ENV !== "test") { attributes.push("Secure"); } return attributes.join("; "); } function readCookieValue(request: Request, name: string): string | null { const header = request.headers.get("cookie"); if (!header) return null; for (const part of header.split(/;\s*/)) { const eq = part.indexOf("="); if (eq === -1) continue; const key = part.slice(0, eq).trim(); if (key === name) return part.slice(eq + 1).trim(); } return null; } /** * Read + verify + invalidate the challenge cookie attached to `request`. * Always returns a `clearCookie` header that the caller must include on * the redirect response so the cookie cannot be replayed. */ export function consumeAdminSsoChallenge( request: Request, options: { now?: () => number; secret?: string } = {} ): ConsumeResult { const value = readCookieValue(request, ADMIN_SSO_CHALLENGE_COOKIE); if (!value) return { ok: false, reason: "missing" }; const dot = value.lastIndexOf("."); if (dot <= 0) return { ok: false, reason: "malformed" }; const body = value.slice(0, dot); const sig = value.slice(dot + 1); let secret: string; try { secret = getSigningSecret(options.secret); } catch { return { ok: false, reason: "malformed" }; } if (!verifySignature(body, sig, secret)) { return { ok: false, reason: "bad-signature" }; } let decoded: { v: string; p: AdminSsoChallengePayload }; try { decoded = JSON.parse(base64UrlDecode(body).toString("utf8")); } catch { return { ok: false, reason: "malformed" }; } if (decoded.v !== PAYLOAD_VERSION || !decoded.p) { return { ok: false, reason: "malformed" }; } const now = options.now ? options.now() : Date.now(); if (typeof decoded.p.createdAt !== "number" || now - decoded.p.createdAt > ADMIN_SSO_CHALLENGE_TTL_MS) { return { ok: false, reason: "expired" }; } return { ok: true, payload: decoded.p, clearCookie: clearAdminSsoChallengeCookie() }; }