import crypto from "node:crypto"; /* Cloudflare Access application-JWT verification for the operations console. * * ops.threadcount.tech sits behind a Cloudflare Access application whose login methods are the * ThreadCount brand of Authentik (OIDC) and a one-time-PIN break-glass. Access puts a signed * assertion on every request it lets through (`Cf-Access-Jwt-Assertion`). Trusting that assertion * is what makes the console single-sign-on: the app mints an operator session from the verified * email instead of asking for a second password. * * Hand-rolled on node:crypto, the way lib/session.ts and lib/totp.ts are, and RS256-ONLY: the * only operation ever performed is an RSA verify, so the "alg: none" / HS256-with-a-public-key * confusion cannot apply. Every check fails CLOSED — a malformed token, wrong issuer or audience, * expired claim or bad signature returns null and the caller falls back to the password door. It * never throws. Active only when both variables are set; without them the console simply has no * SSO, and the fire escape is the whole door. */ const TEAM = process.env.CF_ACCESS_TEAM_DOMAIN; // e.g. example.cloudflareaccess.com const AUD = process.env.CF_ACCESS_AUD; // the Access application's AUD tag const ISSUER = TEAM ? `https://${TEAM}` : null; const CERTS_URL = TEAM ? `https://${TEAM}/cdn-cgi/access/certs` : null; export function accessSsoConfigured(): boolean { return Boolean(TEAM && AUD); } /** Where to land after single sign-on: only a path under /ops, never the sign-in page (a loop), * never a scheme or a protocol-relative host (an open redirect). Anything else → /ops. */ export function safeOpsNext(raw: string | null | undefined): string { if (!raw || (raw !== "/ops" && !raw.startsWith("/ops/")) || raw.startsWith("/ops/login")) return "/ops"; if (/[\\\s]/.test(raw) || raw.includes("://")) return "/ops"; return raw; } type Jwk = { kid?: string; kty?: string; n?: string; e?: string }; let jwksCache: { keys: Jwk[]; fetchedAt: number } | null = null; const JWKS_TTL_MS = 10 * 60 * 1000; async function getKeys(force = false): Promise { if (!force && jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_TTL_MS) return jwksCache.keys; const r = await fetch(CERTS_URL!, { cache: "no-store" }); if (!r.ok) throw new Error("access certs fetch failed"); const j = (await r.json()) as { keys?: Jwk[] }; const keys = Array.isArray(j.keys) ? j.keys : []; jwksCache = { keys, fetchedAt: Date.now() }; return keys; } function decodeSeg(seg: string): Record | null { try { return JSON.parse(Buffer.from(seg, "base64url").toString("utf8")) as Record; } catch { return null; } } /** The verified, lower-cased email, or null on any failure whatsoever. */ export async function verifyAccessJwt(token: string | null | undefined): Promise { if (!token || !TEAM || !AUD || !ISSUER || !CERTS_URL) return null; const parts = token.split("."); if (parts.length !== 3) return null; const [h, p, s] = parts; const header = decodeSeg(h); const payload = decodeSeg(p); if (!header || !payload) return null; if (header.alg !== "RS256" || typeof header.kid !== "string") return null; const now = Math.floor(Date.now() / 1000); const exp = payload.exp; const iat = payload.iat; if (typeof exp !== "number" || exp < now - 5) return null; // expired (small skew) if (typeof iat === "number" && iat > now + 60) return null; // issued in the future if (payload.iss !== ISSUER) return null; const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!auds.includes(AUD)) return null; const rawEmail = payload.email; const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : null; if (!email) return null; let keys: Jwk[]; try { keys = await getKeys(); } catch { return null; } let jwk = keys.find((k) => k.kid === header.kid); if (!jwk) { // Unknown kid — Access rotates keys; refresh once before giving up. try { keys = await getKeys(true); } catch { return null; } jwk = keys.find((k) => k.kid === header.kid); } if (!jwk || jwk.kty !== "RSA" || !jwk.n || !jwk.e) return null; let pub: crypto.KeyObject; try { pub = crypto.createPublicKey({ key: jwk as crypto.JsonWebKey, format: "jwk" }); } catch { return null; } let ok = false; try { ok = crypto.verify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, Buffer.from(s, "base64url")); } catch { return null; } return ok ? email : null; }