feat(auth): Anmeldung per OIDC mit Rollen aus Gruppen

Authorization Code mit PKCE, State und Nonce über openid-client 6.8.8; die Rolle kommt bei jeder Anmeldung aus dem Gruppen-Claim. Lokale Konten werden nie über den Namen übernommen, verknüpfen geht nur angemeldet. Getestet gegen einen Mock-IdP inklusive gefälschtem State, falscher Nonce und fremder Audience.

Closes #016

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:57:50 +02:00
co-authored by Claude Opus 5.5
parent 99357d59bd
commit 6d6b0ff121
15 changed files with 871 additions and 8 deletions
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { aufruferAdresse, istHttps, oeffentlicheAdresse } from "@/lib/adressen";
import { logger } from "@/lib/logger";
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
import {
OIDC_COOKIE,
oidcAbschliessen,
oidcEinstellungen,
OidcFehler,
RUECKRUF_PFAD,
} from "@/server/auth/oidc";
function cookieLesen(kopf: string | null, name: string): string | undefined {
for (const teil of (kopf ?? "").split(";")) {
const [schluessel, ...wert] = teil.trim().split("=");
if (schluessel === name) return decodeURIComponent(wert.join("="));
}
return undefined;
}
/**
* Öffentlich: Rückkehr vom Identity Provider. Erfolg → Sitzungscookie und Weiterleitung zum Ziel;
* Fehler → Anmeldeseite mit Code (`?sso=keine_gruppe` …), nie eine Fehlerseite mit Interna.
*/
export async function GET(anfrage: Request): Promise<Response> {
const herkunft = new URL(anfrage.url).origin;
const e = oidcEinstellungen();
const zurAnmeldung = (code: string) =>
NextResponse.redirect(oeffentlicheAdresse(`/anmelden?sso=${code}`, herkunft), 302);
if (!e) return zurAnmeldung("aus");
// Die öffentliche Adresse: genau die, die als redirect_uri beim IdP registriert ist.
const aktuell = oeffentlicheAdresse(RUECKRUF_PFAD + new URL(anfrage.url).search, herkunft);
let antwort: NextResponse;
try {
const { benutzer, ziel } = await oidcAbschliessen(
e,
aktuell,
cookieLesen(anfrage.headers.get("cookie"), OIDC_COOKIE),
aufruferAdresse(anfrage.headers),
);
antwort = NextResponse.redirect(oeffentlicheAdresse(ziel, herkunft), 302);
antwort.cookies.set(
SITZUNG_COOKIE,
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "oidc" }),
{
httpOnly: true,
sameSite: "lax",
secure: istHttps(anfrage.headers, anfrage.url),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
},
);
} catch (fehler) {
if (!(fehler instanceof OidcFehler)) logger.error({ err: fehler }, "OIDC-Rückruf fehlgeschlagen");
const code = fehler instanceof OidcFehler ? fehler.code : "idp";
// Beim Verknüpfen ist man schon angemeldet – die Meldung gehört dann in die Einstellungen.
antwort =
code === "schon_verknuepft"
? NextResponse.redirect(oeffentlicheAdresse(`/einstellungen?sso=${code}`, herkunft), 302)
: zurAnmeldung(code);
}
antwort.cookies.set(OIDC_COOKIE, "", { path: "/api/v1/auth/oidc", maxAge: 0 });
return antwort;
}