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

106 lines
3.9 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.
*/
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<string, string | undefined> = 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<string, string | undefined> = 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));
}