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:
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 | – |
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+16
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user