import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js"; import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js"; import { OidcStateCapacityError, type AuthSessionStore } from "./session-store.js"; import { rolesToPermissions } from "./config.js"; import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js"; import { requirePermission, isPrincipalContext } from "./authorization.js"; import { deriveCsrfToken } from "./csrf.js"; import { verifyWithDummy } from "./password.js"; import type { OidcProtocol } from "./oidc-client.js"; const TEN_MINUTES_MS = 10 * 60 * 1000; const REMEMBER_COOKIE_SECONDS = 2_592_000; const MAX_USERNAME_LENGTH = 64; const MAX_PASSWORD_LENGTH = 1024; const MAX_LIMIT_ENTRIES = 10_000; const MAX_OIDC_INITIATIONS_PER_ADDRESS = 20; const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096; const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback"; const OIDC_TRANSACTION_COOKIE = "__Host-thothii_oidc_tx"; const OIDC_TRANSACTION_COOKIE_SECONDS = TEN_MINUTES_MS / 1000; const OIDC_VALUE_PATTERN = /^[A-Za-z0-9_-]{43}$/; export interface AuthRouteDependencies { authMode: "local" | "oidc" | "upstream" | "none" | "mock"; authentication?: AuthenticationConfigProvider; sessionStore?: AuthSessionStore; /** Test-only compatibility seam; production resolves from each loaded config snapshot. */ localUserRegistry?: LocalUserRegistry; resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined; oidcProtocol?: OidcProtocol; resolveOidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined; } interface LoginPayload { username: string; password: string; remember: boolean; } function countActiveAttempts( bucket: ReadonlyMap, key: string, now: number, ): number { const attempts = bucket.get(key); if (!attempts) return 0; const earliest = now - TEN_MINUTES_MS; let count = 0; for (const timestamp of attempts) { if (timestamp > earliest) count += 1; } return count; } function pruneExpiredAttempts(bucket: Map, now: number): void { const earliest = now - TEN_MINUTES_MS; for (const [key, attempts] of bucket) { const active = attempts.filter((timestamp) => timestamp > earliest); if (active.length === 0) bucket.delete(key); else if (active.length !== attempts.length) bucket.set(key, active); } } function canRecordAttempt( bucket: ReadonlyMap, key: string, limit: number, maximumEntries: number, ): boolean { return (bucket.get(key)?.length ?? 0) < limit && (bucket.has(key) || bucket.size < maximumEntries); } function appendAttempt(bucket: Map, key: string, now: number): void { bucket.set(key, [...(bucket.get(key) ?? []), now]); } export class LoginFailureLimiter { private readonly usernames = new Map(); private readonly addresses = new Map(); private readonly maximumEntries: number; constructor(options: { maximumEntries?: number } = {}) { this.maximumEntries = options.maximumEntries ?? MAX_LIMIT_ENTRIES; } isLimited(username: string, address: string, now = Date.now()): boolean { return countActiveAttempts(this.usernames, username, now) >= 10 || countActiveAttempts(this.addresses, address, now) >= 20; } recordFailure(username: string, address: string, now = Date.now()): boolean { pruneExpiredAttempts(this.usernames, now); pruneExpiredAttempts(this.addresses, now); if (!canRecordAttempt(this.usernames, username, 10, this.maximumEntries) || !canRecordAttempt(this.addresses, address, 20, this.maximumEntries)) return false; appendAttempt(this.usernames, username, now); appendAttempt(this.addresses, address, now); return true; } } class OidcInitiationLimiter { private readonly addresses = new Map(); consume(address: string, now = Date.now()): boolean { pruneExpiredAttempts(this.addresses, now); if (!canRecordAttempt( this.addresses, address, MAX_OIDC_INITIATIONS_PER_ADDRESS, MAX_LIMIT_ENTRIES, )) return false; appendAttempt(this.addresses, address, now); return true; } } class VerificationGate { private active = 0; async run(operation: () => Promise): Promise { if (this.active >= 2) return undefined; this.active += 1; try { return await operation(); } finally { this.active -= 1; } } } async function unavailableAfterDummy( gate: VerificationGate, password: string, reply: FastifyReply, ): Promise { try { const completed = await gate.run(async () => { await verifyWithDummy(password); return true; }); if (completed === undefined) return loginLimited(reply); } catch { // Preserve the sanitized operational outcome below. } return unavailable(reply); } export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void { const limiter = new LoginFailureLimiter(); const oidcInitiationLimiter = new OidcInitiationLimiter(); const verificationGate = new VerificationGate(); app.get("/auth/config", async (request, reply) => { const snapshot = captureAuthConfigSnapshot(request, deps.authentication); if (!snapshot) return unavailable(reply); const mode = snapshot.value.mode; return reply.send({ mode, localLogin: mode === "local", oidcLogin: mode === "oidc" }); }); app.post("/auth/local/login", async (request, reply) => { const snapshot = captureAuthConfigSnapshot(request, deps.authentication); const configured = currentLocalConfig(snapshot, deps); if (configured.kind === "unavailable") { return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply); } if (configured.kind !== "local" || !deps.sessionStore) return unavailable(reply); const originCheck = requireExactOrigin(request, reply, configured.origin); if (originCheck !== true) return originCheck; const payload = loginPayload(request); const safePassword = argon2SafePassword(payload.password); const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase()); const sourceAddress = boundedAddress(request.ip); if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply); let user: LocalUserRecord | undefined; try { if (payload.username.length > 0) user = await configured.registry.findByUsername(payload.username); } catch { return unavailableAfterDummy(verificationGate, safePassword, reply); } let verified: boolean | undefined; try { verified = await verificationGate.run(async () => configured.registry.verify(user, safePassword)); } catch { return unavailable(reply); } if (verified === undefined) return loginLimited(reply); if (!verified || !user || !user.enabled) { if (!limiter.recordFailure(normalizedUsername, sourceAddress)) return loginLimited(reply); return invalidCredentials(reply); } try { const created = await deps.sessionStore.create({ principal: { issuer: "local", subject: user.id, displayName: user.displayName ?? user.username, roles: user.roles, permissions: rolesToPermissions(user.roles), isAdmin: user.roles.includes("admin"), }, method: "local", remembered: payload.remember, userAuthRevision: user.authRevision, authConfigRevision: configured.revision, idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000, absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000, }); reply.setCookie(sessionCookieName(), created.token, cookieOptions(snapshot, payload.remember)); return reply.send({}); } catch { return unavailable(reply); } }); app.get("/auth/oidc/login", async (request, reply) => { if (!oidcInitiationLimiter.consume(boundedAddress(request.ip))) return loginLimited(reply); const loaded = captureAuthConfigSnapshot(request, deps.authentication); const configured = currentOidcConfig(loaded, deps); if (!configured || !deps.sessionStore) { clearOidcTransactionCookie(reply); return unavailable(reply); } const nonce = randomOidcValue(); const codeVerifier = randomOidcValue(); const browserTransaction = randomOidcValue(); try { const created = await deps.sessionStore.createOidcState({ nonce, codeVerifier, returnTo: "/", authConfigRevision: configured.loaded.revision, issuer: configured.config.oidc.issuer, browserTransactionDigest: oidcTransactionDigest(browserTransaction).toString("hex"), }); try { const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier }); reply.setCookie(OIDC_TRANSACTION_COOKIE, browserTransaction, oidcTransactionCookieOptions()); return reply.redirect(location.href); } catch { await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined); clearOidcTransactionCookie(reply); return unavailable(reply); } } catch (error) { clearOidcTransactionCookie(reply); if (error instanceof OidcStateCapacityError) return loginLimited(reply); return unavailable(reply); } }); app.get("/auth/oidc/callback", async (request, reply) => { const loaded = captureAuthConfigSnapshot(request, deps.authentication); clearOidcTransactionCookie(reply); const callback = oidcCallbackUrl(request, loaded?.value.publicUrl); if (!deps.sessionStore || !callback.state) return oidcCallbackFailed(reply); let state; try { state = await deps.sessionStore.consumeOidcState(callback.state); } catch { return oidcCallbackFailed(reply); } const configured = currentOidcConfig(loaded, deps); if (!callback.currentUrl || !configured || !state || state.returnTo !== "/" || !oidcTransactionMatches(request.cookies[OIDC_TRANSACTION_COOKIE], state.browserTransactionDigest) || state.authConfigRevision !== configured.loaded.revision || state.issuer !== configured.config.oidc.issuer) { return oidcCallbackFailed(reply); } try { const identity = await configured.protocol.callback({ currentUrl: callback.currentUrl, state: callback.state, nonce: state.nonce, codeVerifier: state.codeVerifier, }); if (identity.issuer !== configured.config.oidc.issuer || !Array.isArray(identity.groups) || identity.groups.length === 0) return oidcCallbackFailed(reply); const roles = oidcRoles(identity.groups, configured.config); const now = new Date(); const absoluteTtlMs = Math.min( configured.config.session.oidcTtlSeconds * 1000, identity.tokenExpiresAt.getTime() - now.getTime(), ); if (!Number.isSafeInteger(absoluteTtlMs) || absoluteTtlMs <= 0) return oidcCallbackFailed(reply); const created = await deps.sessionStore.create({ principal: { issuer: identity.issuer, subject: identity.subject, ...(identity.displayName === undefined ? {} : { displayName: identity.displayName }), roles, permissions: rolesToPermissions(roles), isAdmin: roles.includes("admin"), }, method: "oidc", remembered: false, authConfigRevision: configured.loaded.revision, idleTtlMs: configured.config.session.regularIdleSeconds * 1000, absoluteTtlMs, }, now); reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.loaded, false)); return reply.redirect(state.returnTo); } catch { return oidcCallbackFailed(reply); } }); app.post("/auth/logout", async (request, reply) => { const token = request.authSessionToken; if (!token || !deps.sessionStore) return unavailable(reply); try { await deps.sessionStore.revoke(token); reply.clearCookie(sessionCookieName(), cookieOptions(request.authConfigSnapshot, false)); return reply.code(204).send(); } catch { return unavailable(reply); } }); app.get("/me", async (request, reply) => { const principal = requirePermission(request, reply, "session.use"); if (!isPrincipalContext(principal)) return principal; const session = request.authSession; const token = request.authSessionToken; if (!session || !token) { return { issuer: principal.issuer, subject: principal.subject, ...(principal.displayName === undefined ? {} : { displayName: principal.displayName }), roles: principal.roles, permissions: principal.permissions, isAdmin: principal.isAdmin, csrfToken: null, session: null, }; } try { return { issuer: principal.issuer, subject: principal.subject, ...(principal.displayName === undefined ? {} : { displayName: principal.displayName }), roles: principal.roles, permissions: principal.permissions, isAdmin: principal.isAdmin, csrfToken: deriveCsrfToken(token), session: { method: session.method, remembered: session.remembered, idleExpiresAt: session.idleExpiresAt, absoluteExpiresAt: session.absoluteExpiresAt, }, }; } catch { return unavailable(reply); } }); } function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies): | { revision: string; origin: string; session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number }; registry: LocalUserRegistry; kind: "local"; } | { kind: "not_local" } | { kind: "unavailable" } { try { if (!loaded) return { kind: "unavailable" }; if (loaded.value.mode !== "local") return { kind: "not_local" }; const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry; if (!registry) return { kind: "unavailable" }; const url = new URL(loaded.value.publicUrl); return { kind: "local", revision: loaded.revision, origin: url.origin, session: loaded.value.session, registry, }; } catch { return { kind: "unavailable" }; } } function cookieOptions(snapshot: LoadedAuthConfig | undefined, remembered: boolean) { let secure = false; try { secure = snapshot !== undefined && new URL(snapshot.value.publicUrl).protocol === "https:"; } catch { // Invalid auth configurations are rejected before they can reach this route. } return { httpOnly: true, sameSite: "lax" as const, path: "/", secure, ...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}), }; } function currentOidcConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies): | { loaded: LoadedAuthConfig; config: OidcAuthenticationConfig; protocol: OidcProtocol } | undefined { if (!loaded || loaded.value.mode !== "oidc") return undefined; const protocol = deps.resolveOidcProtocol?.(loaded) ?? deps.oidcProtocol; return protocol ? { loaded, config: loaded.value, protocol } : undefined; } function randomOidcValue(): string { return randomBytes(32).toString("base64url"); } function oidcTransactionDigest(value: string): Buffer { return createHash("sha256").update(value, "utf8").digest(); } function oidcTransactionMatches(value: string | undefined, expectedDigest: string): boolean { const canonical = typeof value === "string" && OIDC_VALUE_PATTERN.test(value); const expectedCanonical = /^[a-f0-9]{64}$/.test(expectedDigest); const supplied = oidcTransactionDigest(canonical ? value : ""); const expected = expectedCanonical ? Buffer.from(expectedDigest, "hex") : Buffer.alloc(32); const matches = timingSafeEqual(supplied, expected); return canonical && expectedCanonical && matches; } function oidcTransactionCookieOptions() { return { httpOnly: true, sameSite: "lax" as const, path: "/", secure: true, maxAge: OIDC_TRANSACTION_COOKIE_SECONDS, }; } function clearOidcTransactionCookie(reply: FastifyReply): void { reply.clearCookie(OIDC_TRANSACTION_COOKIE, { httpOnly: true, sameSite: "lax", path: "/", secure: true, }); } function oidcCallbackUrl( request: FastifyRequest, publicUrl: string | undefined, ): { currentUrl?: URL; state?: string } { if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) { return { state: oversizedOidcCallbackState(request.url) }; } let supplied: URL; try { supplied = new URL(request.url, "http://callback.invalid"); } catch { return {}; } if (supplied.pathname !== "/auth/oidc/callback") return {}; const allowed = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]); const copied = new URLSearchParams(); let state: string | undefined; let valid = true; for (const [key, value] of supplied.searchParams) { if (key === "state" && state === undefined && OIDC_VALUE_PATTERN.test(value)) state = value; if (!allowed.has(key) || value.length > 2048 || /\p{Cc}/u.test(value) || copied.has(key)) { valid = false; continue; } copied.set(key, value); } if (!state || !valid || copied.get("state") !== state || publicUrl === undefined) return { state }; let target: URL; try { target = new URL(OIDC_CALLBACK_PATH, publicUrl); } catch { return { state }; } target.search = copied.toString(); return { currentUrl: target, state }; } function oversizedOidcCallbackState(rawUrl: string): string | undefined { const prefix = "/auth/oidc/callback?"; if (!rawUrl.startsWith(prefix)) return undefined; const boundedQuery = rawUrl.slice(prefix.length, MAX_OIDC_CALLBACK_QUERY_LENGTH); let offset = 0; while (offset < boundedQuery.length) { const separator = boundedQuery.indexOf("&", offset); const end = separator === -1 ? boundedQuery.length : separator; const parameter = boundedQuery.slice(offset, end); if (parameter.startsWith("state=")) { const value = parameter.slice("state=".length); if (OIDC_VALUE_PATTERN.test(value)) return value; } if (separator === -1) break; offset = separator + 1; } return undefined; } function oidcCallbackFailed(reply: FastifyReply) { return reply.code(401).send({ code: "oidc_callback_failed", error: "OIDC sign-in could not be completed" }); } function oidcRoles(groups: readonly string[], config: OidcAuthenticationConfig): Role[] { const roles = new Set(); for (const group of groups) { for (const role of config.authorization.groupRoles[group] ?? []) roles.add(role); } return [...roles]; } function loginPayload(request: FastifyRequest): LoginPayload { const body = request.body; if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false }; const input = body as Record; return { username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "", password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "", remember: input.remember === true, }; } function boundedAddress(address: string): string { return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown"; } function argon2SafePassword(value: string): string { const typed = value as string & { isWellFormed?: () => boolean }; const wellFormed = typeof typed.isWellFormed === "function" ? typed.isWellFormed() : !/[\uD800-\uDFFF]/.test(value); const bytes = Buffer.byteLength(value, "utf8"); if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value; // A per-attempt random value preserves the Argon2 work without turning an invalid input into // a reusable password that could happen to match a user's configured secret. return randomBytes(32).toString("base64url"); } function invalidCredentials(reply: FastifyReply): FastifyReply { return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" }); } function loginLimited(reply: FastifyReply): FastifyReply { return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" }); } function unavailable(reply: FastifyReply): FastifyReply { return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); }