/** * 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. */ import { assertLocalCredentialsEnvironment } from "~/utils/db/seed/localCredentialsEnvironment.server"; 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(); let localEnvironmentAllowed = false; try { assertLocalCredentialsEnvironment(env); localEnvironmentAllowed = true; } catch { localEnvironmentAllowed = false; } const enabled = TRUE_VALUES.has(rawEnabled) && localEnvironmentAllowed; 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); } } /** * Enforce the emergency fallback policy only when the resolved identity is an * admin. Keeping this identity-aware guard in one place prevents app-host * login paths from accidentally bypassing the explicit emergency policy. */ export function assertAdminFallbackLoginAllowedForIdentity( identity: { readonly email: string; readonly admin?: unknown | null }, policy?: AdminFallbackPolicy ): void { if (identity.admin) { assertAdminFallbackLoginAllowed(identity.email, policy); } } /** * 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; } export function isCompletedAdminFallbackLoginResponse(response: Response): boolean { if (response.status < 300 || response.status >= 400) { return false; } const setCookie = response.headers.get("set-cookie") ?? ""; return ["__Host-hivegrid_admin_session=", "hivegrid_admin_session="].some((name) => setCookie.includes(name)); }