import "server-only"; import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; import { getBaseUrl } from "@/lib/env"; import { QrRequestError } from "@/lib/qr/type-module"; /** * Anmeldung über einen externen Anbieter (OpenID Connect) — gedacht und * getestet für Authentik, aber nicht darauf festgelegt: Alles Nötige kommt * aus dem Discovery-Dokument des Anbieters. * * Bewusst begrenzt auf die Frage „wer bist du?“. Wer in welcher Gruppe welche * Rolle hat, bleibt Sache der Verwaltung dieser Anwendung — die Rollen hier * bedeuten etwas anderes als die Gruppen im Verzeichnis, und eine automatische * Übersetzung wäre eine Quelle stiller Überraschungen. Einzige Ausnahme ist * die Instanz-Administration, die sich über `OIDC_ADMIN_GROUP` an eine Gruppe * beim Anbieter hängen lässt. */ export type OidcConfig = { issuer: string; clientId: string; clientSecret: string; scopes: string; /** Anzeigename des Knopfs auf der Anmeldeseite. */ buttonLabel: string; /** Gruppe beim Anbieter, die zur Instanz-Administration berechtigt. */ adminGroup: string | null; /** Name des Anspruchs, in dem die Gruppen stehen. */ groupsClaim: string; /** Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen. */ defaultRole: "ADMIN" | "EDITOR" | "VIEWER"; /** Ist die Anmeldung mit Passwort daneben noch erlaubt? */ allowLocalLogin: boolean; }; function env(name: string): string { return process.env[name]?.trim() ?? ""; } /** * Cookie, der State, Nonce und PKCE-Geheimnis über die Weiterleitung zum * Anbieter trägt. Lebt nur für die Dauer des Anmeldevorgangs. */ export const OIDC_FLOW_COOKIE = "qrgen_oidc_flow"; export const OIDC_FLOW_MINUTES = 10; export function isOidcEnabled(): boolean { return env("OIDC_ENABLED").toLowerCase() === "true"; } /** * Liest die Konfiguration. Wirft mit einer Meldung, die sagt, was fehlt — * eine halb eingerichtete Anmeldung soll nicht als „irgendwas ging schief“ * enden. */ export function getOidcConfig(): OidcConfig { const issuer = env("OIDC_ISSUER").replace(/\/+$/, ""); const clientId = env("OIDC_CLIENT_ID"); const clientSecret = env("OIDC_CLIENT_SECRET"); const fehlend = [ !issuer && "OIDC_ISSUER", !clientId && "OIDC_CLIENT_ID", !clientSecret && "OIDC_CLIENT_SECRET", ].filter(Boolean); if (fehlend.length > 0) { throw new QrRequestError( `Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet. Es fehlt: ${fehlend.join(", ")}.`, 500, ); } const rolle = env("OIDC_DEFAULT_ROLE").toUpperCase(); return { issuer, clientId, clientSecret, scopes: env("OIDC_SCOPES") || "openid email profile", buttonLabel: env("OIDC_BUTTON_LABEL") || "Mit Authentik anmelden", adminGroup: env("OIDC_ADMIN_GROUP") || null, groupsClaim: env("OIDC_GROUPS_CLAIM") || "groups", defaultRole: rolle === "ADMIN" || rolle === "EDITOR" || rolle === "VIEWER" ? rolle : "VIEWER", allowLocalLogin: env("OIDC_ONLY").toLowerCase() !== "true", }; } /** Adresse, an die der Anbieter nach der Anmeldung zurückschickt. */ export function redirectUri(): string { return `${getBaseUrl()}/api/auth/oidc/callback`; } /** * Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt. * * Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden * Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die * Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die * beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen * Host, der Cookie geht dabei verloren, und die Anmeldung endet mit * „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche * Ursache gut versteckt. * * Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt. */ export function assertHostMatchesBaseUrl(headers: Headers): void { const angekommen = ( headers.get("x-forwarded-host") ?? headers.get("host") ?? "" ) .split(",")[0] .trim() .toLowerCase(); if (!angekommen) return; let erwartet = ""; try { erwartet = new URL(getBaseUrl()).host.toLowerCase(); } catch { return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen. } if (angekommen === erwartet) return; // Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server // bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf // ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die // interne Adresse der Installation geht Fremde nichts an. console.error( `OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`, ); throw new QrRequestError( `Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` + `In der Konfiguration steht eine andere Adresse, deshalb ginge der ` + `Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` + `findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` + `Protokoll des Containers.`, 400, ); } type Discovery = { issuer: string; authorization_endpoint: string; token_endpoint: string; jwks_uri: string; userinfo_endpoint?: string; end_session_endpoint?: string; }; /** * Das Discovery-Dokument ändert sich praktisch nie, der Abruf kostet aber bei * jeder Anmeldung eine Runde. Deshalb im Speicher halten — eine Stunde ist * kurz genug, um einen Schlüsselwechsel mitzubekommen. */ const DISCOVERY_TTL_MS = 60 * 60 * 1000; let discoveryCache: { url: string; bis: number; wert: Discovery } | null = null; export async function discover(issuer: string): Promise { const url = `${issuer}/.well-known/openid-configuration`; const jetzt = Date.now(); if (discoveryCache && discoveryCache.url === url && discoveryCache.bis > jetzt) { return discoveryCache.wert; } let response: Response; try { response = await fetch(url, { headers: { Accept: "application/json" } }); } catch { throw new QrRequestError( `Der Anmelde-Anbieter ist nicht erreichbar (${issuer}). Läuft er, und stimmt OIDC_ISSUER?`, 502, ); } if (!response.ok) { throw new QrRequestError( `Der Anmelde-Anbieter liefert unter ${url} den Status ${response.status}. Stimmt OIDC_ISSUER?`, 502, ); } const wert = (await response.json()) as Discovery; if (!wert.authorization_endpoint || !wert.token_endpoint || !wert.jwks_uri) { throw new QrRequestError( "Der Anmelde-Anbieter liefert ein unvollständiges Discovery-Dokument.", 502, ); } discoveryCache = { url, bis: jetzt + DISCOVERY_TTL_MS, wert }; return wert; } /** JWKS je Adresse zwischenspeichern — `jose` erneuert bei Bedarf selbst. */ const jwksCache = new Map>(); function jwks(uri: string) { const vorhanden = jwksCache.get(uri); if (vorhanden) return vorhanden; const neu = createRemoteJWKSet(new URL(uri)); jwksCache.set(uri, neu); return neu; } export type OidcIdentity = { subject: string; email: string; emailVerified: boolean; name: string | null; groups: string[]; }; function readGroups(payload: JWTPayload, claim: string): string[] { const wert = payload[claim]; if (Array.isArray(wert)) { return wert.filter((eintrag): eintrag is string => typeof eintrag === "string"); } // Manche Anbieter liefern eine durch Leerzeichen getrennte Liste. if (typeof wert === "string") return wert.split(/\s+/).filter(Boolean); return []; } /** * Tauscht den Code gegen Token und prüft das ID-Token. * * Geprüft wird die Signatur gegen den JWKS des Anbieters sowie Aussteller, * Empfänger und die Nonce. Letztere schließt aus, dass ein anderswo * erbeutetes Token hier eingereicht wird. */ export async function exchangeCode(options: { config: OidcConfig; code: string; codeVerifier: string; nonce: string; }): Promise { const { config, code, codeVerifier, nonce } = options; const meta = await discover(config.issuer); const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri(), client_id: config.clientId, client_secret: config.clientSecret, code_verifier: codeVerifier, }); const response = await fetch(meta.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body, }); if (!response.ok) { // Der Text des Anbieters ist für Betreiber oft hilfreich, gehört aber // nicht ungefiltert in den Browser. const text = await response.text().catch(() => ""); console.error("OIDC: Token-Tausch fehlgeschlagen:", response.status, text); throw new QrRequestError( "Die Anmeldung beim Anbieter ist fehlgeschlagen. Stimmen Client-ID und Geheimnis?", 502, ); } const token = (await response.json()) as { id_token?: string }; if (!token.id_token) { throw new QrRequestError( "Der Anbieter hat kein ID-Token geliefert. Ist der Scope „openid“ erlaubt?", 502, ); } let payload: JWTPayload; try { ({ payload } = await jwtVerify(token.id_token, jwks(meta.jwks_uri), { issuer: meta.issuer || config.issuer, audience: config.clientId, })); } catch (error) { console.error("OIDC: ID-Token nicht gültig:", error); throw new QrRequestError("Das Anmelde-Token des Anbieters ist ungültig.", 502); } if (payload.nonce !== nonce) { throw new QrRequestError( "Die Anmeldung passt nicht zu dieser Sitzung. Bitte versuche es erneut.", 400, ); } const subject = typeof payload.sub === "string" ? payload.sub : ""; const email = typeof payload.email === "string" ? payload.email.trim().toLowerCase() : ""; if (!subject) { throw new QrRequestError("Der Anbieter hat keine Kennung geliefert.", 502); } if (!email) { throw new QrRequestError( "Der Anbieter hat keine E-Mail-Adresse geliefert. Bitte den Scope „email“ freigeben und das Attribut mitgeben.", 502, ); } const name = typeof payload.name === "string" && payload.name.trim().length > 0 ? payload.name.trim() : typeof payload.preferred_username === "string" ? payload.preferred_username.trim() : null; return { subject, email, // Nur ein ausdrückliches `true` zählt. Ein fehlender Claim, `null`, `0` // oder die Zeichenkette „true" gelten als **nicht** bestätigt. // // Vorher stand hier `!== false`, und damit war das Gegenteil der Fall: // Ein Anbieter, der den Claim gar nicht schickt, hätte die Verknüpfung // mit einem bestehenden Konto erlaubt — genau das, was die Prüfung in // der Callback-Route verhindern soll. emailVerified: payload.email_verified === true, name, groups: readGroups(payload, config.groupsClaim), }; }