import { jwtVerify, SignJWT } from "jose"; import * as client from "openid-client"; import type { Benutzer, Rolle } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { logger } from "@/lib/logger"; import { protokollieren } from "@/server/services/protokoll"; /** * Anmeldung per OpenID Connect (z. B. authentik): Authorization Code Flow mit PKCE, State und * Nonce. Die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim – der IdP ist maßgeblich. * * Konten: per `oidcSubjekt` (sub) zugeordnet. Ein lokales Konto wird **nie automatisch** über den * Benutzernamen übernommen – sonst könnte, wer beim IdP „admin“ heißt, den lokalen Admin * übernehmen. Verknüpfen geht nur angemeldet, aus den Einstellungen heraus. */ export type OidcEinstellungen = { issuer: string; clientId: string; clientSecret: string; anzeigename: string; gruppenClaim: string; gruppeAdmin: string; gruppeOperator: string; }; export function oidcEinstellungen( umgebung: Record = process.env, ): OidcEinstellungen | null { if (umgebung["OIDC_AKTIV"] !== "true") return null; const issuer = umgebung["OIDC_ISSUER"]?.trim(); const clientId = umgebung["OIDC_CLIENT_ID"]?.trim(); if (!issuer || !clientId) { logger.warn("OIDC_AKTIV=true, aber OIDC_ISSUER oder OIDC_CLIENT_ID fehlt – OIDC bleibt aus."); return null; } return { issuer, clientId, clientSecret: umgebung["OIDC_CLIENT_SECRET"] ?? "", anzeigename: umgebung["OIDC_ANZEIGENAME"]?.trim() || "SSO", gruppenClaim: umgebung["OIDC_GRUPPEN_CLAIM"]?.trim() || "groups", gruppeAdmin: umgebung["OIDC_GRUPPE_ADMIN"]?.trim() ?? "", gruppeOperator: umgebung["OIDC_GRUPPE_OPERATOR"]?.trim() ?? "", }; } let konfiguration: { schluessel: string; wert: Promise } | undefined; /** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */ async function konfigurationHolen(e: OidcEinstellungen): Promise { const schluessel = `${e.issuer}|${e.clientId}`; if (konfiguration?.schluessel !== schluessel) { const url = new URL(e.issuer); const wert = client.discovery( url, e.clientId, e.clientSecret || undefined, e.clientSecret ? undefined : client.None(), // http nur für Tests und Labore auf localhost – sonst verlangt openid-client zu Recht https. url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname) ? { execute: [client.allowInsecureRequests] } : undefined, ); konfiguration = { schluessel, wert }; wert.catch(() => { if (konfiguration?.wert === wert) konfiguration = undefined; }); } return await konfiguration.wert; } /** Nur für Tests. */ export function oidcZwischenspeicherLeeren(): void { konfiguration = undefined; } // ── Zustand zwischen Start und Rückruf: kurzlebiges, signiertes Cookie ─────────────────────────── export const OIDC_COOKIE = "pd_oidc"; const OIDC_LAUFZEIT_S = 600; type Ablauf = { verifier: string; state: string; nonce: string; ziel: string; verknuepfen: string | null }; function geheimnis(): Uint8Array { return new TextEncoder().encode(process.env["SITZUNG_GEHEIMNIS"] ?? ""); } export const RUECKRUF_PFAD = "/api/v1/auth/oidc/rueckruf"; /** Startet die Anmeldung: Weiterleitungsziel beim IdP und das Cookie für den Rückruf. */ export async function oidcStarten( e: OidcEinstellungen, rueckruf: URL, ziel: string, verknuepfen: string | null, ): Promise<{ weiter: URL; cookie: string }> { const config = await konfigurationHolen(e); const verifier = client.randomPKCECodeVerifier(); const ablauf: Ablauf = { verifier, state: client.randomState(), nonce: client.randomNonce(), ziel, verknuepfen, }; const weiter = client.buildAuthorizationUrl(config, { redirect_uri: rueckruf.href, scope: "openid profile email", code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256", state: ablauf.state, nonce: ablauf.nonce, }); const cookie = await new SignJWT(ablauf) .setProtectedHeader({ alg: "HS256" }) .setAudience("proxdeploy-oidc") .setIssuedAt() .setExpirationTime(`${OIDC_LAUFZEIT_S}s`) .sign(geheimnis()); return { weiter, cookie }; } export class OidcFehler extends Error { constructor( readonly code: "abgelaufen" | "idp" | "keine_gruppe" | "gesperrt" | "name_vergeben" | "schon_verknuepft", nachricht: string, ) { super(nachricht); this.name = "OidcFehler"; } } type Ansprueche = Record & { sub: string }; export function rolleAusGruppen(e: OidcEinstellungen, ansprueche: Ansprueche): Rolle | null { const roh = ansprueche[e.gruppenClaim]; const gruppen = Array.isArray(roh) ? roh.map(String) : typeof roh === "string" ? [roh] : []; if (e.gruppeAdmin && gruppen.includes(e.gruppeAdmin)) return "ADMIN"; if (e.gruppeOperator && gruppen.includes(e.gruppeOperator)) return "OPERATOR"; // Ohne konfigurierte Gruppen genügt die Anmeldung beim IdP für „Operator“. if (!e.gruppeAdmin && !e.gruppeOperator) return "OPERATOR"; return null; } /** Aus preferred_username/E-Mail einen gültigen, freien Benutzernamen machen. */ async function freierName(ansprueche: Ansprueche): Promise { const quelle = String( ansprueche["preferred_username"] ?? String(ansprueche["email"] ?? "").split("@")[0] ?? "", ); let basis = quelle .toLowerCase() .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9._-]/g, "-") .replace(/^[^a-z]+/, "") .slice(0, 28); if (basis.length < 2) basis = "sso-benutzer"; for (let n = 0; n < 50; n++) { const name = n === 0 ? basis : `${basis}-${n + 1}`; if (!(await prisma.benutzer.findUnique({ where: { benutzername: name } }))) return name; } throw new OidcFehler("name_vergeben", "Kein freier Benutzername gefunden."); } /** * Rückruf vom IdP: Code tauschen, ID-Token prüfen (Signatur, Issuer, Audience, Nonce), Konto * finden, anlegen oder verknüpfen, Rolle aus den Gruppen setzen. */ export async function oidcAbschliessen( e: OidcEinstellungen, aktuelleUrl: URL, cookie: string | undefined, ip: string | null, ): Promise<{ benutzer: Benutzer; ziel: string }> { let ablauf: Ablauf; try { const { payload } = await jwtVerify(cookie ?? "", geheimnis(), { algorithms: ["HS256"], audience: "proxdeploy-oidc", }); ablauf = payload as unknown as Ablauf; } catch { throw new OidcFehler( "abgelaufen", "Die Anmeldung hat zu lange gedauert oder kam nicht von hier. Bitte neu starten.", ); } const config = await konfigurationHolen(e); let ansprueche: Ansprueche; try { const antwort = await client.authorizationCodeGrant(config, aktuelleUrl, { pkceCodeVerifier: ablauf.verifier, expectedState: ablauf.state, expectedNonce: ablauf.nonce, idTokenExpected: true, }); ansprueche = antwort.claims() as Ansprueche; } catch (fehler) { logger.warn({ err: fehler }, "OIDC-Rückruf abgelehnt"); throw new OidcFehler("idp", "Der Identity Provider hat die Anmeldung nicht bestätigt."); } const rolle = rolleAusGruppen(e, ansprueche); const anzeigename = String(ansprueche["name"] ?? ansprueche["preferred_username"] ?? "SSO-Benutzer").slice( 0, 80, ); const email = typeof ansprueche["email"] === "string" ? ansprueche["email"] : null; // Verknüpfen: ein angemeldeter lokaler Benutzer hängt sein IdP-Konto an. if (ablauf.verknuepfen) { const vorhanden = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } }); if (vorhanden && vorhanden.id !== ablauf.verknuepfen) { throw new OidcFehler( "schon_verknuepft", `Dieses SSO-Konto gehört schon zu „${vorhanden.benutzername}“.`, ); } const benutzer = await prisma.$transaction(async (tx) => { const b = await tx.benutzer.update({ where: { id: ablauf.verknuepfen! }, data: { oidcSubjekt: ansprueche.sub }, }); await protokollieren( { benutzerId: b.id, quelle: "WEB", aktion: "SSO-Konto verknüpft", ziel: `benutzer:${b.benutzername}`, ip, }, tx, ); return b; }); return { benutzer, ziel: "/einstellungen" }; } if (!rolle) { await protokollieren({ benutzerId: null, quelle: "WEB", aktion: "SSO-Anmeldung ohne passende Gruppe", ziel: `oidc:${ansprueche.sub}`, ip, }); throw new OidcFehler( "keine_gruppe", "Dein SSO-Konto ist für ProxDeploy nicht freigegeben (Gruppe fehlt).", ); } const bekannt = await prisma.benutzer.findUnique({ where: { oidcSubjekt: ansprueche.sub } }); if (bekannt) { if (!bekannt.aktiv) throw new OidcFehler("gesperrt", "Dein Konto ist gesperrt."); const benutzer = await prisma.$transaction(async (tx) => { const b = await tx.benutzer.update({ where: { id: bekannt.id }, data: { rolle, anzeigename, ...(email ? { email } : {}) }, }); if (bekannt.rolle !== rolle) { await protokollieren( { benutzerId: b.id, quelle: "WEB", aktion: "Rolle aus SSO-Gruppen übernommen", ziel: `benutzer:${b.benutzername}`, details: { rolle: [bekannt.rolle, rolle] }, ip, }, tx, ); } await protokollieren({ benutzerId: b.id, quelle: "WEB", aktion: "Angemeldet (SSO)", ip }, tx); return b; }); return { benutzer, ziel: ablauf.ziel }; } const benutzername = await freierName(ansprueche); const benutzer = await prisma.$transaction(async (tx) => { const b = await tx.benutzer.create({ data: { benutzername, anzeigename, email, rolle, oidcSubjekt: ansprueche.sub }, }); await protokollieren( { benutzerId: b.id, quelle: "WEB", aktion: "Benutzer per SSO angelegt", ziel: `benutzer:${b.benutzername}`, details: { rolle }, ip, }, tx, ); return b; }); return { benutzer, ziel: ablauf.ziel }; }