Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/services/auth/admin-fallback.server.ts
T

79 lines
2.8 KiB
TypeScript

/**
* 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<string, string | undefined> = 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<string, string | undefined> = process.env): boolean {
return readAdminFallbackPolicy(env).enabled;
}