193 lines
7.5 KiB
TypeScript
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;
|
|
}
|