133 lines
4.6 KiB
TypeScript
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;
|
|
}
|