import type { NextRequest } from "next/server"; import { cookies } from "next/headers"; import { isAuthEnabled } from "@/lib/auth/actor"; import { exchangeCode, getOidcConfig, isOidcEnabled, OIDC_FLOW_COOKIE, type OidcConfig, type OidcIdentity, } from "@/lib/auth/oidc"; import { createSession } from "@/lib/auth/session"; import { sicherheitsereignis } from "@/lib/auth/protokoll"; import { prisma } from "@/lib/db"; import { getBaseUrl } from "@/lib/env"; export const dynamic = "force-dynamic"; /** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */ function zurueckMitFehler(meldung: string): Response { const ziel = new URL("/anmelden", getBaseUrl()); ziel.searchParams.set("fehler", meldung); return Response.redirect(ziel.toString(), 302); } type FlowState = { state: string; nonce: string; codeVerifier: string }; /** * Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts` * über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in * mehreren Bündeln und hätten dort verschiedene Identitäten. */ function istBenutzerfehler(error: unknown): error is Error { return ( error instanceof Error && (error as Error & { userFacing?: unknown }).userFacing === true ); } function readFlow(raw: string | undefined): FlowState | null { if (!raw) return null; try { const wert = JSON.parse(raw) as Partial; if ( typeof wert.state === "string" && typeof wert.nonce === "string" && typeof wert.codeVerifier === "string" ) { return wert as FlowState; } } catch { // Unbrauchbarer Cookie — behandeln wie keiner. } return null; } /** * Legt das Konto an oder findet es wieder. * * Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die * E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes * Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem * lokal angelegten Administrator nicht plötzlich ein zweites Konto wird. */ async function findeOderLegeAn( identity: OidcIdentity, config: OidcConfig, ): Promise<{ id: string; isActive: boolean } | { fehler: string }> { const nachSubject = await prisma.user.findUnique({ where: { oidcSubject: identity.subject }, }); const istAdmin = config.adminGroup ? identity.groups.includes(config.adminGroup) : undefined; if (nachSubject) { const aktualisiert = await prisma.user.update({ where: { id: nachSubject.id }, data: { email: identity.email, name: identity.name ?? nachSubject.name, lastLoginAt: new Date(), failedLogins: 0, lockedUntil: null, // Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der // Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist. ...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }), }, }); return { id: aktualisiert.id, isActive: aktualisiert.isActive }; } const nachEmail = await prisma.user.findUnique({ where: { email: identity.email }, }); if (nachEmail) { // Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter // jemand eine fremde Adresse eintragen und damit ein hiesiges Konto // übernehmen. if (!identity.emailVerified) { // Der Versuch gehört ins Protokoll: Wenn er gehäuft auftritt, stimmt // entweder die Anbieterkonfiguration nicht — oder jemand probiert es. sicherheitsereignis("oidc-verknuepfung-abgelehnt", { konto: nachEmail.id, grund: "email-nicht-bestaetigt", }); return { fehler: "Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.", }; } const verknuepft = await prisma.user.update({ where: { id: nachEmail.id }, data: { oidcSubject: identity.subject, name: identity.name ?? nachEmail.name, lastLoginAt: new Date(), failedLogins: 0, lockedUntil: null, ...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }), }, }); // Eine Verknüpfung verschiebt eine Vertrauensgrenze: Ab jetzt kommt man // über den Anbieter in dieses Konto. Das soll nachvollziehbar sein. sicherheitsereignis("oidc-konto-verknuepft", { konto: verknuepft.id, administrator: verknuepft.isInstanceAdmin, }); return { id: verknuepft.id, isActive: verknuepft.isActive }; } // Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter. const standardgruppe = await prisma.group.findFirst({ orderBy: { createdAt: "asc" }, select: { id: true }, }); // Der erste Mensch, der sich überhaupt anmeldet, wird Administrator — // sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen // kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung. // // Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon // jemanden?" und dem Anlegen könnte sonst eine zweite Anmeldung // dazwischenkommen, und beide würden sich für die erste halten. Am Ende // stünden zwei Administratoren da, von denen einer ungebeten ist. const angelegt = await prisma.$transaction( async (tx) => { const istErster = (await tx.user.count()) === 0; return tx.user.create({ data: { email: identity.email, name: identity.name, passwordHash: null, oidcSubject: identity.subject, isInstanceAdmin: istAdmin ?? istErster, lastLoginAt: new Date(), memberships: standardgruppe ? { create: { groupId: standardgruppe.id, role: config.defaultRole } } : undefined, }, }); }, { isolationLevel: "Serializable" }, ); if (angelegt.isInstanceAdmin) { sicherheitsereignis("administrator-vergeben", { konto: angelegt.id, weg: istAdmin === undefined ? "erster-benutzer" : "gruppe-des-anbieters", }); } return { id: angelegt.id, isActive: angelegt.isActive }; } export async function GET(request: NextRequest): Promise { const store = await cookies(); try { if (!isAuthEnabled() || !isOidcEnabled()) { return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet."); } const params = request.nextUrl.searchParams; // Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe. const fehler = params.get("error"); if (fehler) { const beschreibung = params.get("error_description"); console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung); return zurueckMitFehler( fehler === "access_denied" ? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?" : "Die Anmeldung beim Anbieter ist fehlgeschlagen.", ); } const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value); if (!flow) { return zurueckMitFehler( "Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.", ); } const code = params.get("code"); const state = params.get("state"); if (!code || !state) { return zurueckMitFehler("Die Antwort des Anbieters war unvollständig."); } if (state !== flow.state) { // Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf. return zurueckMitFehler( "Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.", ); } const config = getOidcConfig(); const identity = await exchangeCode({ config, code, codeVerifier: flow.codeVerifier, nonce: flow.nonce, }); const ergebnis = await findeOderLegeAn(identity, config); if ("fehler" in ergebnis) { return zurueckMitFehler(ergebnis.fehler); } if (!ergebnis.isActive) { return zurueckMitFehler( "Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.", ); } await createSession(ergebnis.id); const ziel = new URL("/", getBaseUrl()); return Response.redirect(ziel.toString(), 302); } catch (error) { console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error); // Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind — // alles andere könnte Interna verraten. const meldung = istBenutzerfehler(error) ? error.message : "Die Anmeldung ist fehlgeschlagen."; return zurueckMitFehler(meldung); } finally { // In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut. store.delete(OIDC_FLOW_COOKIE); } }