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
+4 -1
View File
@@ -22,12 +22,15 @@ ADMIN_BENUTZER=admin
ADMIN_PASSWORT= ADMIN_PASSWORT=
# ── OIDC (optional, z. B. authentik) ───────────────────────────────────────── # ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
# Beim IdP als Redirect-URI eintragen: <APP_URL>/api/v1/auth/oidc/rueckruf
# Client-Typ „confidential“, Scopes openid profile email; der Gruppen-Claim muss im ID-Token stehen.
OIDC_AKTIV=false OIDC_AKTIV=false
OIDC_ISSUER= OIDC_ISSUER=
OIDC_CLIENT_ID= OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET= OIDC_CLIENT_SECRET=
OIDC_ANZEIGENAME=SSO 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_GRUPPEN_CLAIM=groups
OIDC_GRUPPE_ADMIN=proxdeploy-admins OIDC_GRUPPE_ADMIN=proxdeploy-admins
OIDC_GRUPPE_OPERATOR=proxdeploy-operators OIDC_GRUPPE_OPERATOR=proxdeploy-operators
+2
View File
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### 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) - 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) - 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) - 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 ### Entfernt
### Sicherheit ### 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) - Sperren, Passwort-Reset und Passwortwechsel beenden alle bestehenden Sitzungen des Benutzers (#015)
## [0.4.0] – 2026-10-12 ## [0.4.0] – 2026-10-12
+24
View File
@@ -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). 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 `<APP_URL>/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 ## Entwicklung
Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL. Voraussetzungen: Node 24 (z. B. über nvm), pnpm 12 (corepack), Docker für PostgreSQL.
+12 -4
View File
@@ -1,5 +1,5 @@
# #016 Anmeldung per OIDC # #016 Anmeldung per OIDC
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** feature, sicherheit **Labels:** feature, sicherheit
**Geschätzt:** 4 h **Tatsächlich:** – **Geschätzt:** 4 h **Tatsächlich:** –
@@ -8,13 +8,21 @@
Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim. Optionale OIDC-Anmeldung (z. B. authentik), Rollen über Gruppen-Claim.
## Aufgaben ## 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 ## 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 ## Abhängigkeiten
#015 #015
## Notizen / Entscheidungen ## 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.
+1 -1
View File
@@ -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 | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#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 | – | | [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
+1
View File
@@ -40,6 +40,7 @@
"jose": "^6.2.12", "jose": "^6.2.12",
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.4.0", "next": "16.4.0",
"openid-client": "6.8.8",
"pg": "^8.23.0", "pg": "^8.23.0",
"pg-boss": "12.37.1", "pg-boss": "12.37.1",
"pino": "10.4.0", "pino": "10.4.0",
+16
View File
@@ -193,6 +193,9 @@ importers:
next: next:
specifier: 16.4.0 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) 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: pg:
specifier: ^8.23.0 specifier: ^8.23.0
version: 8.23.1 version: 8.23.1
@@ -3183,6 +3186,9 @@ packages:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
oauth4webapi@3.8.8:
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3226,6 +3232,9 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
openid-client@6.8.8:
resolution: {integrity: sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==}
optionator@0.9.4: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -6784,6 +6793,8 @@ snapshots:
non-error@0.1.0: {} non-error@0.1.0: {}
oauth4webapi@3.8.8: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -6832,6 +6843,11 @@ snapshots:
on-exit-leak-free@2.1.2: {} 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: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
+35 -1
View File
@@ -1,5 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { oeffentlicheAdresse } from "@/lib/adressen"; 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 { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang"; import { seiteSchuetzen } from "@/server/auth/zugang";
import { benutzerListe } from "@/server/services/benutzer"; import { benutzerListe } from "@/server/services/benutzer";
@@ -13,8 +16,10 @@ import { TokenVerwaltung } from "./token-verwaltung";
export const metadata: Metadata = { title: "Einstellungen" }; export const metadata: Metadata = { title: "Einstellungen" };
/** Einstellungen: eigene SSH-Schlüssel, CLI-Tokens, Passwort; für Admins die Benutzer. */ /** 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 benutzer = await seiteSchuetzen("eigeneZugaenge", "/einstellungen");
const sso = oidcEinstellungen();
const ssoFehler = (await searchParams).sso;
const admin = darf(benutzer.rolle, "benutzerVerwalten"); const admin = darf(benutzer.rolle, "benutzerVerwalten");
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -30,6 +35,35 @@ export default async function Einstellungen() {
adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin} adresse={oeffentlicheAdresse("/", "http://localhost:3000").origin}
/> />
{benutzer.passwortHash ? <PasswortAendern /> : null} {benutzer.passwortHash ? <PasswortAendern /> : null}
{sso ? (
<Karte titel="Anmeldung per SSO">
<div className="flex flex-col gap-3">
{ssoFehler === "schon_verknuepft" ? (
<Status art="fehler">Dieses SSO-Konto gehört schon zu einem anderen Benutzer.</Status>
) : null}
{benutzer.oidcSubjekt ? (
<Status art="erledigt">
Mit {sso.anzeigename} verknüpft – die Rolle kommt bei jeder SSO-Anmeldung aus den Gruppen.
</Status>
) : (
<>
<p className="text-muted text-sm">
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.
</p>
<div>
<a
href="/api/v1/auth/oidc/start?verknuepfen=1"
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center rounded-lg border px-4 text-sm font-semibold sm:h-10"
>
Mit {sso.anzeigename} verknüpfen
</a>
</div>
</>
)}
</div>
</Karte>
) : null}
{admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null} {admin ? <BenutzerVerwaltung anfang={await benutzerListe()} ich={benutzer.benutzername} /> : null}
</div> </div>
); );
+36 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Status } from "@/components/status";
import { Symbol, Wortmarke } from "@/components/wortmarke"; import { Symbol, Wortmarke } from "@/components/wortmarke";
import { oidcEinstellungen } from "@/server/auth/oidc";
import { aktuellerBenutzer } from "@/server/auth/zugang"; import { aktuellerBenutzer } from "@/server/auth/zugang";
import { Anmeldeformular } from "./anmeldeformular"; import { Anmeldeformular } from "./anmeldeformular";
@@ -11,12 +13,25 @@ function sicheresZiel(ziel: string | string[] | undefined): string {
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/"; return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
} }
const SSO_MELDUNGEN: Record<string, string> = {
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({ export default async function Anmelden({
searchParams, searchParams,
}: { }: {
searchParams: Promise<Record<string, string | string[]>>; searchParams: Promise<Record<string, string | string[]>>;
}) { }) {
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); if (await aktuellerBenutzer()) redirect(ziel);
return ( return (
@@ -29,7 +44,27 @@ export default async function Anmelden({
</h1> </h1>
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p> <p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
</div> </div>
{ssoFehler ? (
<Status art="fehler" className="mb-4 text-sm">
{ssoFehler}
</Status>
) : null}
<Anmeldeformular ziel={ziel} /> <Anmeldeformular ziel={ziel} />
{sso ? (
<div className="mt-6 flex flex-col gap-3">
<div className="text-muted flex items-center gap-3 text-xs" aria-hidden="true">
<span className="bg-linie h-px flex-1" />
oder
<span className="bg-linie h-px flex-1" />
</div>
<a
href={`/api/v1/auth/oidc/start?ziel=${encodeURIComponent(ziel)}`}
className="border-linie bg-panel-hoch text-text hover:border-muted inline-flex h-11 items-center justify-center rounded-lg border px-4 text-sm font-semibold"
>
Mit {sso.anzeigename} anmelden
</a>
</div>
) : null}
</div> </div>
</main> </main>
); );
@@ -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;
}
+44
View File
@@ -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;
});
+300
View File
@@ -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<string, string | undefined> = 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<client.Configuration> } | undefined;
/** Discovery einmal je Issuer; schlägt sie fehl, wird beim nächsten Mal neu versucht. */
async function konfigurationHolen(e: OidcEinstellungen): Promise<client.Configuration> {
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<string, unknown> & { 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<string> {
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 };
}
+117
View File
@@ -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<string, unknown> = { 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<string, unknown> }
>();
private schluessel: Awaited<ReturnType<typeof generateKeyPair>> | undefined;
private jwk: JWK | undefined;
url = "";
async starten(): Promise<this> {
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<void>((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<void> {
await new Promise<void>((fertig) => this.server?.close(() => fertig()));
}
private async bearbeiten(
anfrage: import("node:http").IncomingMessage,
antwort: import("node:http").ServerResponse,
): Promise<void> {
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<string>((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" });
}
}
+210
View File
@@ -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<string, unknown>,
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);
});
});
+4
View File
@@ -13,6 +13,10 @@ const AUSNAHMEN: Record<string, string> = {
"src/app/api/v1/auth/token/route.ts": "src/app/api/v1/auth/token/route.ts":
"proxdeploy login: Passwort gegen Token – prüft das Passwort selbst (mit Sperre)", "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/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/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
"src/app/anmelden/page.tsx": "Anmeldeseite", "src/app/anmelden/page.tsx": "Anmeldeseite",
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis", "src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",