import "server-only"; import { createHash, randomBytes } from "node:crypto"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { env } from "./env"; /** * Schlanke OIDC-Anbindung (Authorization Code + PKCE) für Authelia, Keycloak, * Authentik und Verwandte. * * Bewusst ohne Client-Bibliothek: Flimmer braucht genau einen Ablauf, und der * passt in wenige Dutzend Zeilen — dafür lohnt keine weitere Abhängigkeit mit * eigenem Versionsrhythmus. */ type Discovery = { issuer: string; authorization_endpoint: string; token_endpoint: string; jwks_uri: string; userinfo_endpoint?: string; end_session_endpoint?: string; }; let cached: { at: number; doc: Discovery } | null = null; const DISCOVERY_TTL_MS = 60 * 60 * 1000; export async function discover(): Promise { if (cached && Date.now() - cached.at < DISCOVERY_TTL_MS) return cached.doc; const url = `${env.oidc.issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`; const response = await fetch(url, { cache: "no-store" }); if (!response.ok) { throw new Error(`OIDC-Discovery fehlgeschlagen (${response.status}) für ${url}`); } const doc = (await response.json()) as Discovery; cached = { at: Date.now(), doc }; return doc; } let jwks: ReturnType | null = null; async function getJwks() { if (!jwks) { const doc = await discover(); jwks = createRemoteJWKSet(new URL(doc.jwks_uri)); } return jwks; } export const redirectUri = () => `${env.appUrl}/api/auth/oidc/callback`; /** Erzeugt state und PKCE-Verifier für einen neuen Anmeldeversuch. */ export function createChallenge() { const state = randomBytes(16).toString("base64url"); const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); return { state, verifier, challenge }; } export async function authorizationUrl(state: string, challenge: string): Promise { const doc = await discover(); const url = new URL(doc.authorization_endpoint); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", env.oidc.clientId); url.searchParams.set("redirect_uri", redirectUri()); url.searchParams.set("scope", env.oidc.scopes); url.searchParams.set("state", state); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); return url.toString(); } export type OidcIdentity = { issuer: string; subject: string; email: string | null; name: string | null; }; /** Tauscht den Code gegen Tokens und prüft das ID-Token. */ export async function exchangeCode(code: string, verifier: string): Promise { const doc = await discover(); const response = await fetch(doc.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri(), client_id: env.oidc.clientId, client_secret: env.oidc.clientSecret, code_verifier: verifier, }), cache: "no-store", }); if (!response.ok) { throw new Error(`Token-Tausch fehlgeschlagen (${response.status}).`); } const tokens = (await response.json()) as { id_token?: string; access_token?: string }; if (!tokens.id_token) { throw new Error("Der Anbieter hat kein ID-Token geliefert."); } const { payload } = await jwtVerify(tokens.id_token, await getJwks(), { issuer: doc.issuer, audience: env.oidc.clientId, }); const subject = typeof payload.sub === "string" ? payload.sub : null; if (!subject) throw new Error("Das ID-Token enthält keine Subjekt-Kennung."); let email = typeof payload.email === "string" ? payload.email.toLowerCase() : null; let name = typeof payload.name === "string" ? payload.name : typeof payload.preferred_username === "string" ? payload.preferred_username : null; // Manche Anbieter liefern Profilangaben erst am userinfo-Endpunkt. if ((!email || !name) && doc.userinfo_endpoint && tokens.access_token) { try { const info = await fetch(doc.userinfo_endpoint, { headers: { authorization: `Bearer ${tokens.access_token}` }, cache: "no-store", }); if (info.ok) { const profile = (await info.json()) as Record; if (!email && typeof profile.email === "string") email = profile.email.toLowerCase(); if (!name && typeof profile.name === "string") name = profile.name; } } catch { // Profilangaben sind nur schmückendes Beiwerk — der Login klappt auch ohne. } } return { issuer: doc.issuer, subject, email, name }; }