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

133 lines
4.6 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";
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<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();
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<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).
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<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;
}