From 6d6b0ff121a650e12f052acf2bbd0eb07826471a Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 16:57:50 +0200 Subject: [PATCH] feat(auth): Anmeldung per OIDC mit Rollen aus Gruppen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 5 +- CHANGELOG.md | 2 + README.md | 24 ++ docs/issues/016-oidc.md | 16 +- docs/issues/README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 16 ++ src/app/(app)/einstellungen/page.tsx | 36 ++- src/app/anmelden/page.tsx | 37 ++- src/app/api/v1/auth/oidc/rueckruf/route.ts | 65 +++++ src/app/api/v1/auth/oidc/start/route.ts | 44 +++ src/server/auth/oidc.ts | 300 +++++++++++++++++++++ tests/oidc-mock.ts | 117 ++++++++ tests/oidc.test.ts | 210 +++++++++++++++ tests/zugang.test.ts | 4 + 15 files changed, 871 insertions(+), 8 deletions(-) create mode 100644 src/app/api/v1/auth/oidc/rueckruf/route.ts create mode 100644 src/app/api/v1/auth/oidc/start/route.ts create mode 100644 src/server/auth/oidc.ts create mode 100644 tests/oidc-mock.ts create mode 100644 tests/oidc.test.ts diff --git a/.env.example b/.env.example index e956b22..5a9feb6 100644 --- a/.env.example +++ b/.env.example @@ -22,12 +22,15 @@ ADMIN_BENUTZER=admin ADMIN_PASSWORT= # ── OIDC (optional, z. B. authentik) ───────────────────────────────────────── +# Beim IdP als Redirect-URI eintragen: /api/v1/auth/oidc/rueckruf +# Client-Typ „confidential“, Scopes openid profile email; der Gruppen-Claim muss im ID-Token stehen. OIDC_AKTIV=false OIDC_ISSUER= OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_ANZEIGENAME=SSO -# Gruppen-Claim und Gruppennamen für die Rollen. +# Gruppen-Claim und Gruppennamen für die Rollen. Wer in keiner der beiden Gruppen ist, kommt nicht +# hinein. Beide leer = jeder beim IdP angemeldete Benutzer wird Operator. OIDC_GRUPPEN_CLAIM=groups OIDC_GRUPPE_ADMIN=proxdeploy-admins OIDC_GRUPPE_OPERATOR=proxdeploy-operators diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5b4b7..9d794d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- Anmeldung per OIDC (z. B. authentik): PKCE, State und Nonce, Rolle aus dem Gruppen-Claim bei jeder Anmeldung, neue Konten beim ersten Anmelden, Verknüpfen eines lokalen Kontos aus den Einstellungen (#016) - Benutzerverwaltung für Admins: anlegen, Rolle ändern, sperren und entsperren, Passwort neu setzen; der letzte Admin und das eigene Konto sind geschützt (#015) - Eigenes Passwort in den Einstellungen ändern (#015) - Audit-Log-Ansicht für Admins, filterbar nach Benutzer, Quelle, Text und Zeitraum; auch `GET /api/v1/protokoll` (#015) @@ -24,6 +25,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Entfernt ### Sicherheit +- SSO übernimmt nie ein lokales Konto über den Benutzernamen; Verknüpfen geht nur angemeldet (#016) - Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015) ## [0.4.0] – 2026-10-12 diff --git a/README.md b/README.md index c8deaf5..d2373e7 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,30 @@ guests: Bauen: `cli/bauen.sh 0.4.0` legt die sechs Binaries und `SHA256SUMS` unter `cli/bin/` ab (Go 1.27). +## Anmeldung per OIDC (z. B. authentik) + +Optional zusätzlich zu lokalen Konten. Beim Identity Provider einen vertraulichen Client anlegen, +Redirect-URI `/api/v1/auth/oidc/rueckruf`, Scopes `openid profile email`, den +Gruppen-Claim ins ID-Token geben. Dann in der `.env`: + +```bash +OIDC_AKTIV=true +OIDC_ISSUER=https://auth.example/application/o/proxdeploy/ +OIDC_CLIENT_ID=… +OIDC_CLIENT_SECRET=… +OIDC_ANZEIGENAME=authentik +OIDC_GRUPPEN_CLAIM=groups +OIDC_GRUPPE_ADMIN=proxdeploy-admins +OIDC_GRUPPE_OPERATOR=proxdeploy-operators +``` + +- Ablauf: Authorization Code mit PKCE, State und Nonce; das ID-Token wird gegen die Schlüssel des IdP geprüft. +- Die Rolle kommt bei **jeder** Anmeldung aus den Gruppen; wer in keiner der beiden Gruppen ist, kommt nicht hinein. +- Neue SSO-Benutzer werden beim ersten Anmelden angelegt. Ein vorhandenes lokales Konto wird **nie** + über den Namen übernommen; verknüpfen geht angemeldet unter Einstellungen → „Mit … verknüpfen“. +- Gesperrte Konten bleiben gesperrt, auch per SSO. Getestet gegen einen Mock-IdP (`tests/oidc.test.ts`); + ein echter IdP folgt beim Feldtest. + ## Entwicklung Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL. diff --git a/docs/issues/016-oidc.md b/docs/issues/016-oidc.md index 1197db5..d46e8e4 100644 --- a/docs/issues/016-oidc.md +++ b/docs/issues/016-oidc.md @@ -1,5 +1,5 @@ # #016 Anmeldung per OIDC -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.5.0 **Labels:** feature, sicherheit **Geschätzt:** 4 h **Tatsächlich:** – @@ -8,13 +8,21 @@ Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim. ## Aufgaben -- [ ] openid-client mit PKCE, Discovery, Gruppen → Rolle, Konto-Verknüpfung +- [x] openid-client 6.8.8 mit PKCE, State, Nonce, Discovery; Gruppen → Rolle bei jeder Anmeldung; Konto-Verknüpfung aus den Einstellungen +- [x] Anmeldeseite mit „Mit … anmelden“ und verständlichen Fehlermeldungen (`?sso=…`) ## Akzeptanzkriterien -- [ ] Tests gegen einen Mock-IdP (echter IdP später) +- [x] Tests gegen einen Mock-IdP (`tests/oidc-mock.ts`): Anlegen, Rollenwechsel, keine Gruppe, keine Übernahme per Name, Verknüpfen, gesperrt, gefälschter State, fehlendes Cookie, falsche Nonce, fremde Audience – echter IdP beim Feldtest (#020) ## Abhängigkeiten #015 ## Notizen / Entscheidungen -– +- **Keine automatische Übernahme lokaler Konten über den Namen** – sonst übernähme, wer beim IdP + „admin“ heißt, den lokalen Admin. Bei Namensgleichheit bekommt das SSO-Konto „name-2“. +- **IdP maßgeblich für die Rolle:** bei jeder Anmeldung aus den Gruppen; Wechsel kommt ins Audit-Log. +- Ablaufzustand (Verifier, State, Nonce, Ziel) in einem kurzlebigen signierten Cookie + (`pd_oidc`, 10 min, nur `/api/v1/auth/oidc`, SameSite=Lax) statt in der Datenbank. +- `redirect_uri` und die Rückruf-URL werden aus `APP_URL` gebildet – hinter dem Reverse-Proxy + trägt die Anfrage den internen Host. +- Client-Anmeldung `client_secret_post` (Standard von openid-client); authentik und Keycloak können beides. diff --git a/docs/issues/README.md b/docs/issues/README.md index a773c74..ad15625 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -19,7 +19,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#013](013-cli.md) | CLI proxdeploy (Go) | erledigt | v0.4.0 | 12 h | 0 h 42 min | | [#014](014-verlauf.md) | Verlauf und Wiederholen | erledigt | v0.5.0 | 4 h | – | | [#015](015-rollen-audit.md) | Rollen, Benutzer, Tokens, Audit-Log | erledigt | v0.5.0 | 6 h | – | -| [#016](016-oidc.md) | Anmeldung per OIDC | offen | v0.5.0 | 4 h | – | +| [#016](016-oidc.md) | Anmeldung per OIDC | erledigt | v0.5.0 | 4 h | – | | [#017](017-branding.md) | Branding-Assets | offen | v0.6.0 | 4 h | – | | [#018](018-readme.md) | README und Dokumentation | offen | v0.6.0 | 4 h | – | | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – | diff --git a/package.json b/package.json index 755ed0f..ebb33cc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "jose": "^6.2.12", "lucide-react": "1.47.0", "next": "16.4.0", + "openid-client": "6.8.8", "pg": "^8.23.0", "pg-boss": "12.37.1", "pino": "10.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31c7f06..cfa95f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: next: specifier: 16.4.0 version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + openid-client: + specifier: 6.8.8 + version: 6.8.8 pg: specifier: ^8.23.0 version: 8.23.1 @@ -3183,6 +3186,9 @@ packages: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} engines: {node: '>=20'} + oauth4webapi@3.8.8: + resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3226,6 +3232,9 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + openid-client@6.8.8: + resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6784,6 +6793,8 @@ snapshots: non-error@0.1.0: {} + oauth4webapi@3.8.8: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -6832,6 +6843,11 @@ snapshots: on-exit-leak-free@2.1.2: {} + openid-client@6.8.8: + dependencies: + jose: 6.2.12 + oauth4webapi: 3.8.8 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index d95cf21..5807d96 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; import { oeffentlicheAdresse } from "@/lib/adressen"; +import { Karte } from "@/components/karte"; +import { Status } from "@/components/status"; +import { oidcEinstellungen } from "@/server/auth/oidc"; import { darf } from "@/server/auth/rechte"; import { seiteSchuetzen } from "@/server/auth/zugang"; import { benutzerListe } from "@/server/services/benutzer"; @@ -13,8 +16,10 @@ import { TokenVerwaltung } from "./token-verwaltung"; export const metadata: Metadata = { title: "Einstellungen" }; /** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */ -export default async function Einstellungen() { +export default async function Einstellungen({ searchParams }: { searchParams: Promise<{ sso?: string }> }) { const benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen"); + const sso = oidcEinstellungen(); + const ssoFehler = (await searchParams).sso; const admin = darf(benutzer.rolle, "benutzerVerwalten"); return (
@@ -30,6 +35,35 @@ export default async function Einstellungen() { adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin} /> {benutzer.passwortHash ? : null} + {sso ? ( + +
+ {ssoFehler === "schon_verknuepft" ? ( + Dieses SSO-Konto gehört schon zu einem anderen Benutzer. + ) : null} + {benutzer.oidcSubjekt ? ( + + Mit {sso.anzeigename} verknüpft – die Rolle kommt bei jeder SSO-Anmeldung aus den Gruppen. + + ) : ( + <> +

+ Dein Konto ist noch nicht mit {sso.anzeigename} verknüpft. Danach kannst du dich auch dort + anmelden; die Rolle kommt dann aus den Gruppen beim Identity Provider. +

+ + + )} +
+
+ ) : null} {admin ? : null}
); diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx index f725b0c..bfb7db5 100644 --- a/src/app/anmelden/page.tsx +++ b/src/app/anmelden/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; +import { Status } from "@/components/status"; import { Symbol, Wortmarke } from "@/components/wortmarke"; +import { oidcEinstellungen } from "@/server/auth/oidc"; import { aktuellerBenutzer } from "@/server/auth/zugang"; import { Anmeldeformular } from "./anmeldeformular"; @@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string { return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/"; } +const SSO_MELDUNGEN: Record = { + abgelaufen: "Die SSO-Anmeldung hat zu lange gedauert. Bitte noch einmal.", + idp: "Der Identity Provider hat die Anmeldung nicht bestätigt.", + keine_gruppe: "Dein SSO-Konto ist für ProxDeploy nicht freigegeben – es fehlt die Gruppe.", + gesperrt: "Dein Konto ist gesperrt.", + name_vergeben: "Für dein SSO-Konto ließ sich kein Benutzername vergeben.", + aus: "Die Anmeldung per SSO ist nicht eingerichtet.", +}; + export default async function Anmelden({ searchParams, }: { searchParams: Promise>; }) { - const ziel = sicheresZiel((await searchParams)["ziel"]); + const parameter = await searchParams; + const ziel = sicheresZiel(parameter["ziel"]); + const sso = oidcEinstellungen(); + const ssoFehler = + typeof parameter["sso"] === "string" ? (SSO_MELDUNGEN[parameter["sso"]] ?? SSO_MELDUNGEN["idp"]) : null; if (await aktuellerBenutzer()) redirect(ziel); return ( @@ -29,7 +44,27 @@ export default async function Anmelden({

VMs und LXC-Container auf Proxmox VE ausrollen

+ {ssoFehler ? ( + + {ssoFehler} + + ) : null} + {sso ? ( + + ) : null} ); diff --git a/src/app/api/v1/auth/oidc/rueckruf/route.ts b/src/app/api/v1/auth/oidc/rueckruf/route.ts new file mode 100644 index 0000000..514ee63 --- /dev/null +++ b/src/app/api/v1/auth/oidc/rueckruf/route.ts @@ -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 { + 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; +} diff --git a/src/app/api/v1/auth/oidc/start/route.ts b/src/app/api/v1/auth/oidc/start/route.ts new file mode 100644 index 0000000..16158f4 --- /dev/null +++ b/src/app/api/v1/auth/oidc/start/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { istHttps, oeffentlicheAdresse } from "@/lib/adressen"; +import { api } from "@/server/api"; +import { anfrageAufrufer } from "@/server/auth/zugang"; +import { OIDC_COOKIE, oidcEinstellungen, oidcStarten, RUECKRUF_PFAD } from "@/server/auth/oidc"; +import { AnwendungsFehler, nichtAngemeldet } from "@/server/fehler"; + +/** Nur relative Ziele – sonst wäre `?ziel=` eine offene Weiterleitung. */ +function sicheresZiel(ziel: string | null): string { + return ziel && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/"; +} + +/** + * Öffentlich: leitet zum Identity Provider. `?verknuepfen=1` (angemeldet) hängt das SSO-Konto an + * das eigene lokale Konto statt anzumelden. + */ +export const GET = api(async (anfrage: Request) => { + const e = oidcEinstellungen(); + if (!e) throw new AnwendungsFehler(404, "oidc_aus", "Die Anmeldung per SSO ist nicht eingerichtet."); + const abfrage = new URL(anfrage.url).searchParams; + let verknuepfen: string | null = null; + if (abfrage.get("verknuepfen") === "1") { + const aufrufer = await anfrageAufrufer(anfrage); + if (!aufrufer) throw nichtAngemeldet(); + verknuepfen = aufrufer.benutzer.id; + } + const herkunft = new URL(anfrage.url).origin; + const { weiter, cookie } = await oidcStarten( + e, + oeffentlicheAdresse(RUECKRUF_PFAD, herkunft), + sicheresZiel(abfrage.get("ziel")), + verknuepfen, + ); + const antwort = NextResponse.redirect(weiter, 302); + antwort.cookies.set(OIDC_COOKIE, cookie, { + httpOnly: true, + // Lax: der Rückruf ist eine Top-Level-Navigation vom IdP zurück, da kommt das Cookie mit. + sameSite: "lax", + secure: istHttps(anfrage.headers, anfrage.url), + path: "/api/v1/auth/oidc", + maxAge: 600, + }); + return antwort; +}); diff --git a/src/server/auth/oidc.ts b/src/server/auth/oidc.ts new file mode 100644 index 0000000..a565c09 --- /dev/null +++ b/src/server/auth/oidc.ts @@ -0,0 +1,300 @@ +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 }; +} diff --git a/tests/oidc-mock.ts b/tests/oidc-mock.ts new file mode 100644 index 0000000..2d5efff --- /dev/null +++ b/tests/oidc-mock.ts @@ -0,0 +1,117 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { exportJWK, generateKeyPair, SignJWT, type JWK } from "jose"; + +/** + * Ein Identity Provider für Tests: Discovery, JWKS, Autorisierung (leitet sofort mit Code zurück, + * als hätte sich jemand angemeldet) und Token-Endpunkt mit PKCE-Prüfung und RS256-ID-Token. + * `naechsteAnsprueche` legt fest, wer sich „anmeldet“; `manipulieren` verfälscht Antworten. + */ +export class OidcMock { + readonly clientId = "proxdeploy"; + readonly clientSecret = "geheim-fuer-tests"; + naechsteAnsprueche: Record = { sub: "u-1", preferred_username: "anna" }; + manipulieren: { nonce?: string; aud?: string } = {}; + private server: Server | undefined; + private codes = new Map< + string, + { nonce: string; challenge: string; redirect: string; ansprueche: Record } + >(); + private schluessel: Awaited> | undefined; + private jwk: JWK | undefined; + url = ""; + + async starten(): Promise { + this.schluessel = await generateKeyPair("RS256"); + this.jwk = { ...(await exportJWK(this.schluessel.publicKey)), kid: "k1", alg: "RS256", use: "sig" }; + this.server = createServer((anfrage, antwort) => void this.bearbeiten(anfrage, antwort)); + await new Promise((fertig) => this.server!.listen(0, "127.0.0.1", fertig)); + this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`; + return this; + } + + async stoppen(): Promise { + await new Promise((fertig) => this.server?.close(() => fertig())); + } + + private async bearbeiten( + anfrage: import("node:http").IncomingMessage, + antwort: import("node:http").ServerResponse, + ): Promise { + const url = new URL(anfrage.url ?? "/", this.url); + const json = (status: number, daten: unknown) => { + antwort.writeHead(status, { "content-type": "application/json" }); + antwort.end(JSON.stringify(daten)); + }; + if (url.pathname === "/.well-known/openid-configuration") { + return json(200, { + issuer: this.url, + authorization_endpoint: `${this.url}/authorize`, + token_endpoint: `${this.url}/token`, + jwks_uri: `${this.url}/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + }); + } + if (url.pathname === "/jwks") return json(200, { keys: [this.jwk] }); + if (url.pathname === "/authorize") { + const p = url.searchParams; + if (p.get("client_id") !== this.clientId || p.get("code_challenge_method") !== "S256") { + return json(400, { error: "invalid_request" }); + } + const code = randomBytes(16).toString("hex"); + this.codes.set(code, { + nonce: p.get("nonce") ?? "", + challenge: p.get("code_challenge") ?? "", + redirect: p.get("redirect_uri") ?? "", + ansprueche: this.naechsteAnsprueche, + }); + const zurueck = new URL(p.get("redirect_uri")!); + zurueck.searchParams.set("code", code); + zurueck.searchParams.set("state", p.get("state") ?? ""); + antwort.writeHead(302, { location: zurueck.href }); + antwort.end(); + return; + } + if (url.pathname === "/token" && anfrage.method === "POST") { + const rumpf = new URLSearchParams( + await new Promise((fertig) => { + let text = ""; + anfrage.on("data", (stueck) => (text += stueck)); + anfrage.on("end", () => fertig(text)); + }), + ); + const erwartet = `Basic ${Buffer.from(`${encodeURIComponent(this.clientId)}:${encodeURIComponent(this.clientSecret)}`).toString("base64")}`; + // Beide gängigen Arten der Client-Anmeldung, wie authentik und Keycloak auch. + const perPost = + rumpf.get("client_id") === this.clientId && rumpf.get("client_secret") === this.clientSecret; + if (anfrage.headers.authorization !== erwartet && !perPost) + return json(401, { error: "invalid_client" }); + const eintrag = this.codes.get(rumpf.get("code") ?? ""); + this.codes.delete(rumpf.get("code") ?? ""); + const challenge = createHash("sha256") + .update(rumpf.get("code_verifier") ?? "") + .digest("base64url"); + if (!eintrag || eintrag.challenge !== challenge || eintrag.redirect !== rumpf.get("redirect_uri")) { + return json(400, { error: "invalid_grant" }); + } + const idToken = await new SignJWT({ + ...eintrag.ansprueche, + nonce: this.manipulieren.nonce ?? eintrag.nonce, + }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(this.url) + .setAudience(this.manipulieren.aud ?? this.clientId) + .setSubject(String(eintrag.ansprueche["sub"])) + .setIssuedAt() + .setExpirationTime("5m") + .sign(this.schluessel!.privateKey); + return json(200, { access_token: "zugang", token_type: "Bearer", expires_in: 300, id_token: idToken }); + } + json(404, { error: "not_found" }); + } +} diff --git a/tests/oidc.test.ts b/tests/oidc.test.ts new file mode 100644 index 0000000..a70662d --- /dev/null +++ b/tests/oidc.test.ts @@ -0,0 +1,210 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import * as ich from "@/app/api/v1/ich/route"; +import * as rueckruf from "@/app/api/v1/auth/oidc/rueckruf/route"; +import * as start from "@/app/api/v1/auth/oidc/start/route"; +import { oidcEinstellungen, oidcZwischenspeicherLeeren, rolleAusGruppen } from "@/server/auth/oidc"; +import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db"; +import { angemeldet } from "./helpers/anfragen"; +import { OidcMock } from "./oidc-mock"; + +const basis = { + issuer: "https://idp", + clientId: "x", + clientSecret: "", + anzeigename: "SSO", + gruppenClaim: "groups", + gruppeAdmin: "pd-admins", + gruppeOperator: "pd-ops", +}; + +describe("Rollen aus Gruppen", () => { + it("Admin vor Operator, ohne passende Gruppe kein Zugang, ohne konfigurierte Gruppen Operator", () => { + expect(rolleAusGruppen(basis, { sub: "a", groups: ["pd-ops", "pd-admins"] })).toBe("ADMIN"); + expect(rolleAusGruppen(basis, { sub: "a", groups: "pd-ops" })).toBe("OPERATOR"); + expect(rolleAusGruppen(basis, { sub: "a", groups: ["andere"] })).toBeNull(); + expect(rolleAusGruppen(basis, { sub: "a" })).toBeNull(); + expect(rolleAusGruppen({ ...basis, gruppeAdmin: "", gruppeOperator: "" }, { sub: "a" })).toBe("OPERATOR"); + }); + + it("OIDC nur mit OIDC_AKTIV=true und Issuer + Client-ID", () => { + expect( + oidcEinstellungen({ OIDC_AKTIV: "false", OIDC_ISSUER: "https://i", OIDC_CLIENT_ID: "c" }), + ).toBeNull(); + expect(oidcEinstellungen({ OIDC_AKTIV: "true", OIDC_ISSUER: "https://i" })).toBeNull(); + expect( + oidcEinstellungen({ OIDC_AKTIV: "true", OIDC_ISSUER: "https://i", OIDC_CLIENT_ID: "c" }), + ).toMatchObject({ + anzeigename: "SSO", + gruppenClaim: "groups", + }); + }); +}); + +describe.runIf(datenbankVerfuegbar)("Anmeldung per OIDC gegen einen Mock-IdP", () => { + const idp = new OidcMock(); + const umgebung = { ...process.env }; + + beforeAll(async () => { + await idp.starten(); + Object.assign(process.env, { + OIDC_AKTIV: "true", + OIDC_ISSUER: idp.url, + OIDC_CLIENT_ID: idp.clientId, + OIDC_CLIENT_SECRET: idp.clientSecret, + OIDC_GRUPPEN_CLAIM: "groups", + OIDC_GRUPPE_ADMIN: "pd-admins", + OIDC_GRUPPE_OPERATOR: "pd-ops", + APP_URL: "http://proxdeploy.test", + }); + oidcZwischenspeicherLeeren(); + }); + afterAll(async () => { + process.env = umgebung; + oidcZwischenspeicherLeeren(); + await idp.stoppen(); + }); + beforeEach(async () => { + await datenbankLeeren(); + idp.manipulieren = {}; + }); + + const cookieAus = (antwort: Response, name: string) => + antwort.headers + .getSetCookie() + .map((c) => c.split(";")[0]!) + .find((c) => c.startsWith(`${name}=`) && c.length > name.length + 1); + + /** Der ganze Weg: Start → IdP → Rückruf. Liefert die Antwort des Rückrufs. */ + async function durchlaufen( + ansprueche: Record, + o: { sitzung?: string; verknuepfen?: boolean; stateFaelschen?: boolean; ohneCookie?: boolean } = {}, + ) { + idp.naechsteAnsprueche = ansprueche; + const anfang = await start.GET( + new Request( + `http://intern:3000/api/v1/auth/oidc/start?ziel=/server${o.verknuepfen ? "&verknuepfen=1" : ""}`, + { + headers: o.sitzung ? { cookie: o.sitzung } : {}, + }, + ), + ); + expect(anfang.status).toBe(302); + const weiter = new URL(anfang.headers.get("location")!); + expect(weiter.origin).toBe(idp.url); + // Die redirect_uri ist die öffentliche Adresse (APP_URL), nicht der interne Host. + expect(weiter.searchParams.get("redirect_uri")).toBe("http://proxdeploy.test/api/v1/auth/oidc/rueckruf"); + expect(weiter.searchParams.get("code_challenge_method")).toBe("S256"); + const ablauf = cookieAus(anfang, "pd_oidc")!; + + const beimIdp = await fetch(weiter, { redirect: "manual" }); + const zurueck = new URL(beimIdp.headers.get("location")!); + if (o.stateFaelschen) zurueck.searchParams.set("state", "gefaelscht"); + // Der Reverse-Proxy reicht die Anfrage mit internem Host durch. + return await rueckruf.GET( + new Request(`http://intern:3000${zurueck.pathname}${zurueck.search}`, { + headers: o.ohneCookie ? {} : { cookie: [ablauf, o.sitzung].filter(Boolean).join("; ") }, + }), + ); + } + + it("legt beim ersten Mal ein Konto mit Rolle aus den Gruppen an und meldet an", async () => { + const antwort = await durchlaufen({ + sub: "u-1", + preferred_username: "Anna.Ä", + name: "Anna Ä", + groups: ["pd-admins"], + }); + expect(antwort.status).toBe(302); + expect(antwort.headers.get("location")).toBe("http://proxdeploy.test/server"); + const sitzung = cookieAus(antwort, "pd_sitzung")!; + const wer = await ( + await ich.GET(new Request("http://x/api/v1/ich", { headers: { cookie: sitzung } })) + ).json(); + expect(wer).toMatchObject({ benutzername: "anna.a", rolle: "ADMIN" }); + const b = await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-1" } }); + expect(b.passwortHash).toBeNull(); + }); + + it("übernimmt bei jeder Anmeldung die Rolle aus den Gruppen und protokolliert den Wechsel", async () => { + await durchlaufen({ sub: "u-1", preferred_username: "anna", groups: ["pd-admins"] }); + await durchlaufen({ sub: "u-1", preferred_username: "anna", groups: ["pd-ops"] }); + expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-1" } })).rolle).toBe( + "OPERATOR", + ); + const eintrag = await testPrisma.protokoll.findFirstOrThrow({ + where: { aktion: "Rolle aus SSO-Gruppen übernommen" }, + }); + expect(eintrag.details).toEqual({ rolle: ["ADMIN", "OPERATOR"] }); + expect(await testPrisma.benutzer.count()).toBe(1); + }); + + it("ohne passende Gruppe: kein Konto, Hinweis auf der Anmeldeseite", async () => { + const antwort = await durchlaufen({ sub: "u-2", preferred_username: "gast", groups: ["andere"] }); + expect(antwort.headers.get("location")).toBe("http://proxdeploy.test/anmelden?sso=keine_gruppe"); + expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined(); + expect(await testPrisma.benutzer.count()).toBe(0); + }); + + it("übernimmt nie ein lokales Konto über den Namen", async () => { + const lokal = await angemeldet("ADMIN", "admin"); + await durchlaufen({ sub: "u-3", preferred_username: "admin", groups: ["pd-ops"] }); + const neu = await testPrisma.benutzer.findUniqueOrThrow({ where: { oidcSubjekt: "u-3" } }); + expect(neu.benutzername).toBe("admin-2"); + expect(neu.rolle).toBe("OPERATOR"); + expect( + (await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).oidcSubjekt, + ).toBeNull(); + }); + + it("verknüpfen nur angemeldet; danach landet die SSO-Anmeldung im lokalen Konto", async () => { + const lokal = await angemeldet("OPERATOR", "florian"); + const ohne = await start.GET(new Request("http://intern:3000/api/v1/auth/oidc/start?verknuepfen=1")); + expect(ohne.status).toBe(401); + + const verknuepft = await durchlaufen( + { sub: "u-4", preferred_username: "fw", groups: ["pd-admins"] }, + { sitzung: lokal.cookie, verknuepfen: true }, + ); + expect(verknuepft.headers.get("location")).toBe("http://proxdeploy.test/einstellungen"); + expect( + (await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).oidcSubjekt, + ).toBe("u-4"); + + await durchlaufen({ sub: "u-4", preferred_username: "fw", groups: ["pd-admins"] }); + expect(await testPrisma.benutzer.count()).toBe(1); + expect((await testPrisma.benutzer.findUniqueOrThrow({ where: { id: lokal.benutzer.id } })).rolle).toBe( + "ADMIN", + ); + + const anderer = await angemeldet("OPERATOR", "zweiter"); + const doppelt = await durchlaufen( + { sub: "u-4", groups: ["pd-ops"] }, + { sitzung: anderer.cookie, verknuepfen: true }, + ); + expect(doppelt.headers.get("location")).toBe("http://proxdeploy.test/einstellungen?sso=schon_verknuepft"); + }); + + it("gesperrt bleibt gesperrt", async () => { + await durchlaufen({ sub: "u-5", preferred_username: "max", groups: ["pd-ops"] }); + await testPrisma.benutzer.update({ where: { oidcSubjekt: "u-5" }, data: { aktiv: false } }); + const antwort = await durchlaufen({ sub: "u-5", preferred_username: "max", groups: ["pd-ops"] }); + expect(antwort.headers.get("location")).toContain("sso=gesperrt"); + expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined(); + }); + + it.each([ + ["gefälschter State", { stateFaelschen: true }, {}, "idp"], + ["fehlendes Ablauf-Cookie", { ohneCookie: true }, {}, "abgelaufen"], + ["falsche Nonce im ID-Token", {}, { nonce: "fremd" }, "idp"], + ["ID-Token für einen anderen Client", {}, { aud: "anderer-client" }, "idp"], + ] as const)("%s wird abgewiesen", async (_name, optionen, manipulation, code) => { + idp.manipulieren = { ...manipulation }; + const antwort = await durchlaufen( + { sub: "u-6", preferred_username: "eve", groups: ["pd-admins"] }, + optionen, + ); + expect(antwort.headers.get("location")).toBe(`http://proxdeploy.test/anmelden?sso=${code}`); + expect(cookieAus(antwort, "pd_sitzung")).toBeUndefined(); + expect(await testPrisma.benutzer.count()).toBe(0); + }); +}); diff --git a/tests/zugang.test.ts b/tests/zugang.test.ts index e152cd1..dd64448 100644 --- a/tests/zugang.test.ts +++ b/tests/zugang.test.ts @@ -13,6 +13,10 @@ const AUSNAHMEN: Record = { "src/app/api/v1/auth/token/route.ts": "proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)", "src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen", + "src/app/api/v1/auth/oidc/start/route.ts": + "SSO-Start ist der Weg zur Anmeldung; Verknüpfen prüft die Sitzung selbst", + "src/app/api/v1/auth/oidc/rueckruf/route.ts": + "Rückkehr vom IdP; prüft State, PKCE, Nonce und ID-Token selbst", "src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version", "src/app/anmelden/page.tsx": "Anmeldeseite", "src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",