import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify"; import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js"; import { rolesToPermissions } from "./config.js"; import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord, LoadedAuthConfig } from "./types.js"; import { AuthSessionOperationalError, type AuthSessionStore, type AuthSessionValidity } from "./session-store.js"; import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js"; import { requireSameOriginOrNonBrowser } from "./authorization.js"; declare module "fastify" { interface FastifyRequest { principal?: PrincipalContext; authSession?: AuthSessionRecord; /** Internal only: never serialize or write this opaque cookie token to logs. */ authSessionToken?: string; authPublicOrigin?: string; /** One immutable configuration load for the whole request, including CORS. */ authConfigSnapshot?: LoadedAuthConfig; authConfigSnapshotCaptured?: boolean; authConfigSnapshotUnavailable?: boolean; } } const SESSION_COOKIE = "thothii_session"; const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/; const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); export interface AuthDependencies { mode: AuthMode; publicExposure?: boolean; authentication?: AuthenticationConfigProvider; sessionStore?: AuthSessionStore; sessionValidityForSnapshot?: (snapshot: LoadedAuthConfig) => AuthSessionValidity; } /** Capture the authentication configuration once; CORS calls this before every other hook. */ export function captureAuthConfigSnapshot( request: FastifyRequest, authentication: AuthenticationConfigProvider | undefined, ): LoadedAuthConfig | undefined { if (request.authConfigSnapshotCaptured) return request.authConfigSnapshot; request.authConfigSnapshotCaptured = true; try { request.authConfigSnapshot = authentication?.current(); } catch { request.authConfigSnapshotUnavailable = true; } return request.authConfigSnapshot; } export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) { return async (req: FastifyRequest, reply: FastifyReply) => { if (mode === "none") { req.principal = localPrincipal(publicExposure); } else if (mode === "mock") { const subject = typeof req.headers["x-mock-user"] === "string" ? req.headers["x-mock-user"].trim() : "mock"; const elevated = req.headers["x-thoth-is-admin"] === "1" || req.headers["x-thoth-is-admin"] === "true"; const roles = elevated ? ["admin"] as const : ["user"] as const; req.principal = { issuer: "mock", subject: subject || "mock", displayName: subject || "mock", roles, permissions: rolesToPermissions(roles), isAdmin: elevated, }; } else { const principal = upstreamPrincipal(req.headers); if (!principal) { return reply.code(401).send({ error: "authenticated upstream identity required" }); } req.principal = principal; } }; } /** * The one application boundary for principal resolution. Auth protocol endpoints are the only * public exceptions; all other routes get either a resolved principal or a sanitized denial. */ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandler { const legacy = deps.mode === "none" || deps.mode === "mock" || deps.mode === "upstream" ? authPreHandler(deps.mode, deps.publicExposure) : undefined; const handle = async (request: FastifyRequest, reply: FastifyReply): Promise => { const snapshot = captureAuthConfigSnapshot(request, deps.authentication); if (isPublicRoute(request)) return; const operator = loopbackMaintenancePrincipal(request); if (operator) { request.principal = operator; return; } if (legacy) { await legacy(request, reply); if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return; return requireSameOriginOrNonBrowser(request, reply); } const origin = configuredOrigin(snapshot); if (!snapshot || !origin || !deps.sessionStore) { return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); } const token = readSessionCookie(request); if (token === undefined || token === false) return authenticationRequired(reply); let session: AuthSessionRecord | undefined; try { session = await deps.sessionStore.resolve(token, undefined, deps.sessionValidityForSnapshot?.(snapshot)); if (session && session.authConfigRevision !== snapshot.revision) { try { await deps.sessionStore.revoke(token); } catch { /* the mismatch remains denied */ } return authenticationRequired(reply); } if (session) await deps.sessionStore.touch(token); } catch (error) { if (error instanceof AuthSessionOperationalError) { return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); } return authenticationRequired(reply); } if (!session) return authenticationRequired(reply); request.authSession = session; request.authSessionToken = token; request.authPublicOrigin = origin; request.principal = { issuer: session.issuer, subject: session.subject, ...(session.displayName === undefined ? {} : { displayName: session.displayName }), roles: session.roles, permissions: session.permissions, isAdmin: session.roles.includes("admin"), }; if (STATE_CHANGING_METHODS.has(request.method)) { requireCsrf(request, reply); return; } }; return (request, reply, done) => { void handle(request, reply).then( () => done(), () => { if (!reply.sent) reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); done(); }, ); }; } function loopbackMaintenancePrincipal(request: FastifyRequest): PrincipalContext | undefined { if (request.ip !== "127.0.0.1" && request.ip !== "::1" && request.ip !== "::ffff:127.0.0.1") return undefined; if (singleHeader(request.headers["x-thoth-principal-issuer"]) !== "tht" || singleHeader(request.headers["x-thoth-principal-subject"]) !== "tht-maintenance" || singleHeader(request.headers["x-thoth-principal-display-name"]) !== "Tht maintenance" || singleHeader(request.headers["x-thoth-is-admin"]) !== "1") return undefined; return upstreamPrincipal(request.headers); } export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply { const expectedOrigin = request.authPublicOrigin; const token = request.authSessionToken; if (!expectedOrigin || !token) return authenticationRequired(reply); if (!matchesOrigin(request, expectedOrigin)) return csrfFailed(reply); const header = singleHeader(request.headers["x-thothii-csrf"]); const supplied = header === false || header === undefined || !SESSION_TOKEN.test(header) ? undefined : header; let expected = ""; try { expected = deriveCsrfToken(token); } catch { return authenticationRequired(reply); } if (!csrfTokensEqual(expected, supplied)) return csrfFailed(reply); return true; } /** Require an exact configured public origin and browser Fetch Metadata when supplied. */ export function requireExactOrigin( request: FastifyRequest, reply: FastifyReply, expectedOrigin: string, ): true | FastifyReply { return matchesOrigin(request, expectedOrigin) ? true : csrfFailed(reply); } export function sessionCookieName(): string { return SESSION_COOKIE; } function authenticationRequired(reply: FastifyReply): FastifyReply { return reply.code(401).send({ code: "authentication_required", error: "Authentication is required" }); } function csrfFailed(reply: FastifyReply): FastifyReply { return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" }); } export function configuredOrigin(snapshot: LoadedAuthConfig | undefined): string | undefined { try { const publicUrl = snapshot?.value.publicUrl; return publicUrl ? new URL(publicUrl).origin : undefined; } catch { return undefined; } } function readSessionCookie(request: FastifyRequest): string | false | undefined { const raw = request.headers.cookie; if (raw === undefined) return undefined; if (Array.isArray(raw) || typeof raw !== "string" || raw.length > 4096) return false; const values = raw.split(";").filter((part) => /^\s*thothii_session(?:=|\s*$)/.test(part)); if (values.length !== 1) return values.length === 0 ? undefined : false; const match = /^\s*thothii_session=([A-Za-z0-9_-]{43})\s*$/.exec(values[0]); return match?.[1] ?? false; } function singleHeader(value: string | string[] | undefined): string | false | undefined { if (value === undefined) return undefined; if (Array.isArray(value) || typeof value !== "string" || value.includes(",")) return false; return value; } function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean { const origin = singleHeader(request.headers.origin); try { if (origin === undefined || origin === false || new URL(origin).origin !== expectedOrigin) return false; } catch { return false; } const fetchSite = singleHeader(request.headers["sec-fetch-site"]); return fetchSite === undefined || fetchSite === "same-origin"; } function isPublicRoute(request: FastifyRequest): boolean { const rawUrl = request.raw.url ?? request.url; const query = rawUrl.indexOf("?"); const pathname = query === -1 ? rawUrl : rawUrl.slice(0, query); return (request.method === "GET" && (pathname === "/health" || pathname === "/auth/config" || pathname === "/auth/oidc/login" || pathname === "/auth/oidc/callback")) || (request.method === "POST" && pathname === "/auth/local/login"); } export function getPrincipal(req: FastifyRequest): PrincipalContext { if (!req.principal) throw new Error("principal missing after authentication"); return req.principal; }