Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/admin-sso/client.server.ts
T

193 lines
7.5 KiB
TypeScript

/**
* Phase 09 Task #82 — Admin-Panel OIDC Login (Authentik SSO).
*
* Thin wrapper around `openid-client` (panva/node-openid-client). Provides a
* lazily-initialized, process-wide singleton OIDC `Client` configured against
* the Authentik discovery document. The discovery doc and the JWKS are cached
* inside openid-client itself — we just cache the `Client` instance.
*
* Initialization failures throw `AdminSsoConfigError`; callers (the
* login/callback routes) translate that into a fallback-banner on the login
* page rather than a 500.
*/
import { Issuer, type Client } from "openid-client";
import { createHash } from "node:crypto";
export type AdminSsoEnv = {
readonly issuerUrl: string;
readonly clientId: string;
readonly clientSecret: string;
readonly bindingDatabaseUrl: string;
readonly redirectUri: string;
readonly postLogoutRedirectUri?: string;
};
export class AdminSsoConfigError extends Error {
readonly code: "missing-env" | "invalid-env" | "discovery-failed" | "client-init-failed";
constructor(code: AdminSsoConfigError["code"], message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "AdminSsoConfigError";
this.code = code;
}
}
/**
* Reads the Authentik OIDC env vars and validates that all required keys are
* present. Throws `AdminSsoConfigError("missing-env")` if any are missing.
* Kept env-record-shaped so tests can pass a fake without polluting process.env.
*/
export function readAdminSsoEnv(env: Record<string, string | undefined> = process.env): AdminSsoEnv {
const issuerUrl = env.AUTHENTIK_ISSUER_URL?.trim();
const clientId = env.AUTHENTIK_ADMIN_CLIENT_ID?.trim();
const clientSecret = env.AUTHENTIK_ADMIN_CLIENT_SECRET?.trim();
// The env key and database role retain their deployed legacy names. The
// capability itself binds prepared identities and never creates AdminUsers.
const bindingDatabaseUrl = env.AUTHENTIK_ADMIN_JIT_DATABASE_URL?.trim();
const redirectUri = env.AUTHENTIK_ADMIN_REDIRECT_URI?.trim();
const postLogoutRedirectUri = env.AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI?.trim();
const missing: string[] = [];
if (!issuerUrl) missing.push("AUTHENTIK_ISSUER_URL");
if (!clientId) missing.push("AUTHENTIK_ADMIN_CLIENT_ID");
if (!clientSecret) missing.push("AUTHENTIK_ADMIN_CLIENT_SECRET");
if (!bindingDatabaseUrl) missing.push("AUTHENTIK_ADMIN_JIT_DATABASE_URL");
if (!redirectUri) missing.push("AUTHENTIK_ADMIN_REDIRECT_URI");
if (missing.length > 0) {
throw new AdminSsoConfigError("missing-env", `Admin SSO env is incomplete: missing ${missing.join(", ")}`);
}
const issuer = validateAdminSsoUrl("AUTHENTIK_ISSUER_URL", issuerUrl as string);
const redirect = validateAdminSsoUrl("AUTHENTIK_ADMIN_REDIRECT_URI", redirectUri as string);
validateAuthentikBindingDatabaseUrl(bindingDatabaseUrl as string);
if (issuer.search || issuer.hash) {
throw new AdminSsoConfigError("invalid-env", "AUTHENTIK_ISSUER_URL must not include query parameters or a fragment.");
}
if (redirect.pathname !== "/auth/admin/callback" || redirect.search || redirect.hash) {
throw new AdminSsoConfigError("invalid-env", "AUTHENTIK_ADMIN_REDIRECT_URI must target the exact /auth/admin/callback path.");
}
if (postLogoutRedirectUri) {
const postLogout = validateAdminSsoUrl("AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI", postLogoutRedirectUri);
if (postLogout.origin !== redirect.origin || postLogout.hash) {
throw new AdminSsoConfigError(
"invalid-env",
"AUTHENTIK_ADMIN_POST_LOGOUT_REDIRECT_URI must use the admin callback origin and must not include a fragment.",
);
}
}
return {
issuerUrl: issuerUrl as string,
clientId: clientId as string,
clientSecret: clientSecret as string,
bindingDatabaseUrl: bindingDatabaseUrl as string,
redirectUri: redirectUri as string,
postLogoutRedirectUri: postLogoutRedirectUri || undefined,
};
}
function validateAuthentikBindingDatabaseUrl(value: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new AdminSsoConfigError("invalid-env", "AUTHENTIK_ADMIN_JIT_DATABASE_URL must be a valid PostgreSQL URL.");
}
if (!new Set(["postgres:", "postgresql:"]).has(url.protocol) || url.username !== "hivegrid_authentik_jit" || !url.password || url.pathname === "/") {
throw new AdminSsoConfigError(
"invalid-env",
"AUTHENTIK_ADMIN_JIT_DATABASE_URL must authenticate as hivegrid_authentik_jit and select a database.",
);
}
}
function validateAdminSsoUrl(name: string, value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new AdminSsoConfigError("invalid-env", `${name} must be an absolute URL.`);
}
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
throw new AdminSsoConfigError("invalid-env", `${name} must use HTTPS outside loopback development.`);
}
if (url.username || url.password) {
throw new AdminSsoConfigError("invalid-env", `${name} must not contain URL credentials.`);
}
return url;
}
/**
* Returns true iff SSO is configured (i.e. all required env vars are set).
* Used by the login UI to decide whether to render the SSO button.
*/
export function isAdminSsoConfigured(env: Record<string, string | undefined> = process.env): boolean {
try {
readAdminSsoEnv(env);
return true;
} catch {
return false;
}
}
let cachedClient: Promise<Client> | null = null;
let cachedEnvFingerprint: string | null = null;
function envFingerprint(env: AdminSsoEnv): string {
// Fingerprint identifies whether we need to re-init when env changes
// (mostly relevant for tests; in production env is static for the
// lifetime of the process).
const secretFingerprint = createHash("sha256").update(env.clientSecret).digest("base64url");
const bindingFingerprint = createHash("sha256").update(env.bindingDatabaseUrl).digest("base64url");
return `${env.issuerUrl}|${env.clientId}|${env.redirectUri}|${secretFingerprint}|${bindingFingerprint}`;
}
/**
* Returns a process-wide singleton OIDC client configured against Authentik.
* The first call performs Issuer.discover() — subsequent calls return the
* cached instance.
*/
export async function getAdminSsoClient(env: AdminSsoEnv = readAdminSsoEnv()): Promise<Client> {
const fingerprint = envFingerprint(env);
if (cachedClient && cachedEnvFingerprint === fingerprint) {
return cachedClient;
}
cachedEnvFingerprint = fingerprint;
cachedClient = (async () => {
let issuer;
try {
issuer = await Issuer.discover(env.issuerUrl);
} catch (cause) {
cachedClient = null;
cachedEnvFingerprint = null;
throw new AdminSsoConfigError("discovery-failed", `Authentik OIDC discovery failed for ${env.issuerUrl}`, { cause });
}
try {
return new issuer.Client({
client_id: env.clientId,
client_secret: env.clientSecret,
redirect_uris: [env.redirectUri],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
});
} catch (cause) {
cachedClient = null;
cachedEnvFingerprint = null;
throw new AdminSsoConfigError("client-init-failed", "Failed to construct Authentik OIDC client", { cause });
}
})();
return cachedClient;
}
/** Test-only: reset the cached client. Do not call from production code. */
export function __resetAdminSsoClientForTests(): void {
cachedClient = null;
cachedEnvFingerprint = null;
}