/** * Phase 09 Task #82 — Email/password fallback-login policy for the admin * panel. * * The fallback path stays in the code so operators can recover when * Authentik is unreachable. It is gated by: * - `ADMIN_FALLBACK_LOGIN_ENABLED` env (boolean, default false) * - `ADMIN_FALLBACK_LOGIN_EMAILS` env (comma-separated allowlist) * * Both gates must pass before a non-SSO login succeeds. Callers should * invoke `assertAdminFallbackLoginAllowed` before validating credentials * and audit-log every fallback attempt via the admin-sso audit helper. */ const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); export type AdminFallbackPolicy = { readonly enabled: boolean; readonly allowlist: readonly string[]; }; export function readAdminFallbackPolicy(env: Record = process.env): AdminFallbackPolicy { const rawEnabled = (env.ADMIN_FALLBACK_LOGIN_ENABLED ?? "").trim().toLowerCase(); const enabled = TRUE_VALUES.has(rawEnabled); const rawList = env.ADMIN_FALLBACK_LOGIN_EMAILS ?? ""; const allowlist = rawList .split(",") .map((entry) => entry.trim().toLowerCase()) .filter((entry) => entry.length > 0); return { enabled, allowlist }; } export type FallbackEvaluation = | { readonly ok: true; readonly policy: AdminFallbackPolicy } | { readonly ok: false; readonly reason: "disabled" | "email-not-allowlisted" }; export function evaluateAdminFallbackLogin( email: string, policy: AdminFallbackPolicy = readAdminFallbackPolicy() ): FallbackEvaluation { if (!policy.enabled) { return { ok: false, reason: "disabled" }; } const normalized = email.trim().toLowerCase(); if (!policy.allowlist.includes(normalized)) { return { ok: false, reason: "email-not-allowlisted" }; } return { ok: true, policy }; } export class AdminFallbackLoginRejected extends Error { readonly reason: "disabled" | "email-not-allowlisted"; constructor(reason: "disabled" | "email-not-allowlisted") { super(`Admin fallback login rejected: ${reason}`); this.name = "AdminFallbackLoginRejected"; this.reason = reason; } } /** * Guard intended for the email/password login handler. Throws (not * returns) so callers get the same shape as `verifyUserHasPermission`. */ export function assertAdminFallbackLoginAllowed(email: string, policy?: AdminFallbackPolicy): void { const result = evaluateAdminFallbackLogin(email, policy); if (!result.ok) { throw new AdminFallbackLoginRejected(result.reason); } } /** * UI-side helper: true iff the admin login page should reveal the * fallback toggle at all. The actual email-allowlist check happens * server-side on submit. */ export function isAdminFallbackUiVisible(env: Record = process.env): boolean { return readAdminFallbackPolicy(env).enabled; }