79 lines
2.8 KiB
TypeScript
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;
|
|
}
|