/** * 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"; export type AdminSsoEnv = { readonly issuerUrl: string; readonly clientId: string; readonly clientSecret: string; readonly redirectUri: string; readonly postLogoutRedirectUri?: string; }; export class AdminSsoConfigError extends Error { readonly code: "missing-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 = 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(); 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 (!redirectUri) missing.push("AUTHENTIK_ADMIN_REDIRECT_URI"); if (missing.length > 0) { throw new AdminSsoConfigError("missing-env", `Admin SSO env is incomplete: missing ${missing.join(", ")}`); } return { issuerUrl: issuerUrl as string, clientId: clientId as string, clientSecret: clientSecret as string, redirectUri: redirectUri as string, postLogoutRedirectUri: postLogoutRedirectUri || undefined, }; } /** * 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 = process.env): boolean { try { readAdminSsoEnv(env); return true; } catch { return false; } } let cachedClient: Promise | 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). return `${env.issuerUrl}|${env.clientId}|${env.redirectUri}`; } /** * 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 { 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; }