/** * Phase 09 Task #82 — Admin-SSO logout helpers. * * Stores the raw OIDC `id_token` in a separate HMAC-signed HttpOnly * cookie (scoped to /auth/admin) so that /auth/admin/logout can supply * `id_token_hint` for RP-initiated logout without bloating the main * session cookie or persisting tokens in the DB. */ import { createHmac, timingSafeEqual } from "node:crypto"; import { loadRequiredWebConfig } from "@toolctrl/config"; export const ADMIN_SSO_ID_TOKEN_COOKIE = "admin_sso_id_token"; export const ADMIN_SSO_ID_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // mirror app-session lifetime const COOKIE_PATH = "/auth/admin"; 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 id-token cookie."); } return secret; } function sign(payload: string, secret: string): string { return createHmac("sha256", secret).update(payload).digest("base64url"); } export function serializeIdTokenCookie(idToken: string, opts: { secret?: string } = {}): string { const secret = getSigningSecret(opts.secret); const sig = sign(idToken, secret); const value = `${Buffer.from(idToken, "utf8").toString("base64url")}.${sig}`; const maxAge = Math.ceil(ADMIN_SSO_ID_TOKEN_TTL_MS / 1000); const attrs = [`${ADMIN_SSO_ID_TOKEN_COOKIE}=${value}`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`]; if (process.env.NODE_ENV !== "test") attrs.push("Secure"); return attrs.join("; "); } export function clearIdTokenCookie(): string { const attrs = [`${ADMIN_SSO_ID_TOKEN_COOKIE}=`, `Path=${COOKIE_PATH}`, "HttpOnly", "SameSite=Lax", "Max-Age=0"]; if (process.env.NODE_ENV !== "test") attrs.push("Secure"); return attrs.join("; "); } function readCookie(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; if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); } return null; } export function readIdTokenFromRequest(request: Request, opts: { secret?: string } = {}): string | null { const value = readCookie(request, ADMIN_SSO_ID_TOKEN_COOKIE); if (!value) return null; const dot = value.lastIndexOf("."); if (dot <= 0) return null; const encoded = value.slice(0, dot); const sig = value.slice(dot + 1); let secret: string; try { secret = getSigningSecret(opts.secret); } catch { return null; } const expectedSig = sign(Buffer.from(encoded, "base64url").toString("utf8"), secret); const a = Buffer.from(sig); const b = Buffer.from(expectedSig); if (a.length !== b.length) return null; if (!timingSafeEqual(a, b)) return null; return Buffer.from(encoded, "base64url").toString("utf8"); }