From 7f8ad716b61c600af43132d3d612700e3fa82f9b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 18:46:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20optionale=20SSO-Anmeldung=20=C3=BCber?= =?UTF-8?q?=20OIDC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization Code mit PKCE, ID-Token gegen die JWKS des Anbieters geprüft. Ohne Client-Bibliothek: Flimmer braucht genau einen Ablauf, dafür lohnt keine weitere Abhängigkeit mit eigenem Versionsrhythmus. Bekannte SSO-Kennungen werden verknüpft, sonst über die E-Mail-Adresse einem vorhandenen Konto zugeordnet; das Anlegen neuer Konten lässt sich per OIDC_AUTO_REGISTER unterbinden. --- src/lib/oidc.ts | 143 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/lib/oidc.ts diff --git a/src/lib/oidc.ts b/src/lib/oidc.ts new file mode 100644 index 0000000..66fc5e0 --- /dev/null +++ b/src/lib/oidc.ts @@ -0,0 +1,143 @@ +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 }; +}