Anmeldung ueber Authentik und andere OIDC-Anbieter
Anmeldefluss mit PKCE (S256), State und Nonce. Das ID-Token wird gegen den JWKS des Anbieters geprueft — Signatur, Aussteller, Empfaenger, Ablauf und Nonce; alles Noetige kommt aus dem Discovery-Dokument, damit die Anbindung nicht auf Authentik festgelegt ist. Wiedererkannt wird jemand an der Kennung des Anbieters (sub), nicht an der E-Mail-Adresse — Adressen aendern sich. Ein bestehendes Konto mit derselben Adresse wird verknuepft statt verdoppelt, aber nur bei bestaetigter Adresse: sonst koennte sich beim Anbieter jemand eine fremde Adresse eintragen und damit ein hiesiges Konto uebernehmen. Uebernommen wird bewusst nur, wer jemand ist — plus die Instanz-Administration, wenn OIDC_ADMIN_GROUP gesetzt ist. Gruppen und Rollen bleiben Sache dieser Anwendung: die Gruppen im Verzeichnis bedeuten etwas anderes als die hier. Konten aus dem Verzeichnis haben kein Passwort mehr (passwordHash ist jetzt optional); Konto-Bereich und Passwort-Anmeldung sagen das statt an einer leeren Pruefung zu scheitern. Anleitung mit allen Feldern in Authentik: docs/authentik.md
This commit is contained in:
@@ -26,6 +26,37 @@ AUTH_ENABLED=false
|
|||||||
# grosse Werte machen die Sicherung entsprechend schwer.
|
# grosse Werte machen die Sicherung entsprechend schwer.
|
||||||
MAX_UPLOAD_MB=10
|
MAX_UPLOAD_MB=10
|
||||||
|
|
||||||
|
# --- Anmeldung ueber Authentik oder einen anderen OIDC-Anbieter -------------
|
||||||
|
# Setzt AUTH_ENABLED=true voraus. Einrichtung siehe docs/authentik.md.
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
|
||||||
|
# Basis-URL des Anbieters, unter der /.well-known/openid-configuration liegt.
|
||||||
|
# Bei Authentik: https://auth.example.com/application/o/<slug>/
|
||||||
|
OIDC_ISSUER=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Beschriftung des Knopfs auf der Anmeldeseite.
|
||||||
|
OIDC_BUTTON_LABEL=Mit Authentik anmelden
|
||||||
|
|
||||||
|
# Angeforderte Bereiche. "openid" ist Pflicht, "email" wird gebraucht.
|
||||||
|
OIDC_SCOPES=openid email profile
|
||||||
|
|
||||||
|
# Nur noch ueber den Anbieter anmelden? Dann verschwindet das Passwortfeld.
|
||||||
|
# Vorsicht: Faellt der Anbieter aus, kommt niemand mehr hinein — zum Loesen
|
||||||
|
# hier auf false setzen und neu starten.
|
||||||
|
OIDC_ONLY=false
|
||||||
|
|
||||||
|
# Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen.
|
||||||
|
# VIEWER (nur lesen), EDITOR (Codes anlegen und aendern) oder ADMIN.
|
||||||
|
OIDC_DEFAULT_ROLE=VIEWER
|
||||||
|
|
||||||
|
# Gruppe beim Anbieter, deren Mitglieder die Verwaltung oeffnen duerfen.
|
||||||
|
# Leer lassen, um die Administratorrechte hier in der Anwendung zu pflegen.
|
||||||
|
OIDC_ADMIN_GROUP=
|
||||||
|
# Name des Anspruchs, in dem die Gruppen stehen (Authentik: groups).
|
||||||
|
OIDC_GROUPS_CLAIM=groups
|
||||||
|
|
||||||
# --- PostgreSQL ------------------------------------------------------------
|
# --- PostgreSQL ------------------------------------------------------------
|
||||||
POSTGRES_USER=qrcode
|
POSTGRES_USER=qrcode
|
||||||
POSTGRES_PASSWORD=bitte-aendern
|
POSTGRES_PASSWORD=bitte-aendern
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
- [Konfiguration](#konfiguration)
|
- [Konfiguration](#konfiguration)
|
||||||
- [Bedienung](#bedienung)
|
- [Bedienung](#bedienung)
|
||||||
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||||
|
- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc)
|
||||||
- [Technischer Aufbau](#technischer-aufbau)
|
- [Technischer Aufbau](#technischer-aufbau)
|
||||||
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
- [Entwicklung ohne Docker](#entwicklung-ohne-docker)
|
||||||
- [API](#api)
|
- [API](#api)
|
||||||
@@ -655,6 +656,37 @@ zuweisen. Eine Gruppe, die noch Codes enthält, lässt sich nicht löschen —
|
|||||||
sonst führten gedruckte Codes ins Leere.
|
sonst führten gedruckte Codes ins Leere.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## Anmeldung über Authentik (OIDC)
|
||||||
|
|
||||||
|
Statt Passwörter hier zu pflegen, lässt sich die Anmeldung an einen
|
||||||
|
OpenID-Connect-Anbieter abgeben — getestet mit **Authentik**, ebenso nutzbar
|
||||||
|
mit Keycloak, Zitadel oder Entra ID.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
|
||||||
|
OIDC_CLIENT_ID=…
|
||||||
|
OIDC_CLIENT_SECRET=…
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf der Anmeldeseite erscheint dann ein Knopf für den Anbieter; das
|
||||||
|
Passwortfeld bleibt daneben stehen, bis `OIDC_ONLY=true` gesetzt wird.
|
||||||
|
|
||||||
|
**Übernommen wird, wer jemand ist** — Kennung, E-Mail-Adresse, Anzeigename —
|
||||||
|
und auf Wunsch über `OIDC_ADMIN_GROUP` das Recht zur Verwaltung. Gruppen und
|
||||||
|
Rollen bleiben Sache dieser Anwendung: Die Gruppen im Verzeichnis bedeuten
|
||||||
|
etwas anderes als die hier, und eine automatische Übersetzung wäre eine Quelle
|
||||||
|
stiller Überraschungen.
|
||||||
|
|
||||||
|
Bestehende Konten mit Passwort werden beim ersten Anmelden anhand der
|
||||||
|
**bestätigten** E-Mail-Adresse verknüpft, nicht verdoppelt.
|
||||||
|
|
||||||
|
→ **Vollständige Anleitung mit allen Feldern in Authentik, Fehlerbildern und
|
||||||
|
dem Weg zurück: [docs/authentik.md](docs/authentik.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Technischer Aufbau
|
## Technischer Aufbau
|
||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ services:
|
|||||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||||
|
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
OIDC_BUTTON_LABEL: ${OIDC_BUTTON_LABEL:-Mit Authentik anmelden}
|
||||||
|
OIDC_SCOPES: ${OIDC_SCOPES:-openid email profile}
|
||||||
|
OIDC_ONLY: ${OIDC_ONLY:-false}
|
||||||
|
OIDC_DEFAULT_ROLE: ${OIDC_DEFAULT_ROLE:-VIEWER}
|
||||||
|
OIDC_ADMIN_GROUP: ${OIDC_ADMIN_GROUP:-}
|
||||||
|
OIDC_GROUPS_CLAIM: ${OIDC_GROUPS_CLAIM:-groups}
|
||||||
ports:
|
ports:
|
||||||
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
||||||
- "${APP_PORT:-3000}:3000"
|
- "${APP_PORT:-3000}:3000"
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
# Anmeldung über Authentik (OIDC)
|
||||||
|
|
||||||
|
Diese Anleitung richtet die Anmeldung am QR-Code-Generator über **Authentik**
|
||||||
|
ein. Sie funktioniert genauso mit anderen OpenID-Connect-Anbietern — Keycloak,
|
||||||
|
Zitadel, Entra ID —, weil die Anwendung alles Nötige aus dem
|
||||||
|
Discovery-Dokument des Anbieters liest. Die Beispiele nennen Authentik, weil
|
||||||
|
die Feldnamen dort so heißen.
|
||||||
|
|
||||||
|
**Zeitbedarf:** etwa 15 Minuten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorher klären
|
||||||
|
|
||||||
|
| Frage | Antwort für diese Anleitung |
|
||||||
|
| --- | --- |
|
||||||
|
| Adresse der Anwendung | `https://qr.example.com` |
|
||||||
|
| Adresse von Authentik | `https://auth.example.com` |
|
||||||
|
| Läuft die Anwendung über HTTPS? | Ja — siehe Hinweis unten |
|
||||||
|
|
||||||
|
> **HTTPS ist Pflicht, sobald es über den eigenen Rechner hinausgeht.** Beim
|
||||||
|
> Anmelden gehen Token über die Leitung, und der Sitzungs-Cookie wird nur bei
|
||||||
|
> `https://` in der `BASE_URL` als `Secure` gesetzt. Für einen Versuch im LAN
|
||||||
|
> geht auch HTTP; produktiv gehört ein Reverse Proxy davor (siehe README,
|
||||||
|
> Abschnitt „Hinter einem Reverse Proxy“).
|
||||||
|
|
||||||
|
Die Anwendung muss Authentik erreichen können und umgekehrt der Browser beide.
|
||||||
|
Laufen beide in Docker auf demselben Host, genügt es meist, sie ins selbe
|
||||||
|
Netz zu hängen — der Browser braucht aber in jedem Fall öffentlich auflösbare
|
||||||
|
Adressen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1 — In Authentik einen Provider anlegen
|
||||||
|
|
||||||
|
**Applications → Providers → Create → OAuth2/OpenID Provider**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
| --- | --- |
|
||||||
|
| Name | `QR-Code-Generator` |
|
||||||
|
| Authorization flow | `default-provider-authorization-explicit-consent` (oder implicit, wenn niemand zustimmen soll) |
|
||||||
|
| Client type | **Confidential** |
|
||||||
|
| Client ID | notieren — kommt gleich in die `.env` |
|
||||||
|
| Client Secret | notieren — ebenfalls |
|
||||||
|
| Redirect URIs | `https://qr.example.com/api/auth/oidc/callback` |
|
||||||
|
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
||||||
|
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
|
||||||
|
|
||||||
|
Zu den **Scopes**: `openid`, `email` und `profile` müssen ausgewählt sein.
|
||||||
|
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
|
||||||
|
Anmeldung mit einer entsprechenden Meldung ab.
|
||||||
|
|
||||||
|
> **Redirect URI genau so eintragen.** Ein fehlendes `https://`, ein Schrägstrich
|
||||||
|
> zu viel oder ein anderer Hostname führt zu `redirect_uri mismatch` —
|
||||||
|
> Authentik bricht dann ab, bevor die Anwendung überhaupt etwas mitbekommt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2 — Eine Application anlegen
|
||||||
|
|
||||||
|
**Applications → Applications → Create**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
| --- | --- |
|
||||||
|
| Name | `QR-Code-Generator` |
|
||||||
|
| Slug | `qrcode-generator` |
|
||||||
|
| Provider | der eben angelegte Provider |
|
||||||
|
| Launch URL | `https://qr.example.com` |
|
||||||
|
|
||||||
|
Der **Slug** steckt gleich in der Issuer-Adresse — merken.
|
||||||
|
|
||||||
|
Wer den Zugang einschränken will, hängt hier unter **Policy / Group / User
|
||||||
|
Bindings** eine Gruppe ein. Das ist der empfohlene Weg: Wer sich bei Authentik
|
||||||
|
anmelden darf, kommt sonst auch in den QR-Code-Generator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3 — Die Issuer-Adresse herausfinden
|
||||||
|
|
||||||
|
Authentik zeigt sie beim Provider unter **Provider Info** als *OpenID
|
||||||
|
Configuration Issuer*. Sie sieht so aus:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://auth.example.com/application/o/qrcode-generator/
|
||||||
|
```
|
||||||
|
|
||||||
|
Prüfen lässt sich das direkt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://auth.example.com/application/o/qrcode-generator/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Kommt ein JSON-Dokument mit `authorization_endpoint`, `token_endpoint` und
|
||||||
|
`jwks_uri` zurück, stimmt die Adresse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4 — Die `.env` ergänzen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
OIDC_ENABLED=true
|
||||||
|
OIDC_ISSUER=https://auth.example.com/application/o/qrcode-generator/
|
||||||
|
OIDC_CLIENT_ID=<Client ID aus Schritt 1>
|
||||||
|
OIDC_CLIENT_SECRET=<Client Secret aus Schritt 1>
|
||||||
|
OIDC_BUTTON_LABEL=Mit Authentik anmelden
|
||||||
|
OIDC_SCOPES=openid email profile
|
||||||
|
OIDC_DEFAULT_ROLE=EDITOR
|
||||||
|
OIDC_ADMIN_GROUP=qr-admins
|
||||||
|
OIDC_GROUPS_CLAIM=groups
|
||||||
|
OIDC_ONLY=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach neu starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> `OIDC_ENABLED` allein genügt nicht — **`AUTH_ENABLED` muss ebenfalls `true`
|
||||||
|
> sein**. Ohne Anmeldung gibt es keine Benutzer, denen eine Anmeldung
|
||||||
|
> zugeordnet werden könnte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 5 — Ausprobieren
|
||||||
|
|
||||||
|
`https://qr.example.com/anmelden` aufrufen. Über dem Passwortfeld steht jetzt
|
||||||
|
**„Mit Authentik anmelden“**. Ein Klick führt zu Authentik, nach der Anmeldung
|
||||||
|
geht es zurück in die Anwendung.
|
||||||
|
|
||||||
|
Alle Anmeldenden bekommen die Rolle aus `OIDC_DEFAULT_ROLE` in der
|
||||||
|
Standardgruppe. Wer die **Verwaltung** öffnen darf, hängt davon ab, ob
|
||||||
|
`OIDC_ADMIN_GROUP` gesetzt ist:
|
||||||
|
|
||||||
|
- **Leer:** Der erste Mensch, der sich anmeldet, wird Administrator dieser
|
||||||
|
Installation — genau wie bei der Ersteinrichtung mit Passwort. Danach vergibt
|
||||||
|
man das Recht unter Verwaltung → Benutzer.
|
||||||
|
- **Gesetzt:** Dann entscheidet allein die Gruppe, und zwar bei **jeder**
|
||||||
|
Anmeldung neu — auch beim allerersten Benutzer.
|
||||||
|
|
||||||
|
> **Achtung beim ersten Mal:** Wenn du `OIDC_ADMIN_GROUP` setzt, vergewissere
|
||||||
|
> dich, dass dein eigenes Konto in dieser Gruppe ist. Sonst hat niemand Zugang
|
||||||
|
> zur Verwaltung. Der Weg zurück ist einfach: die Variable leeren,
|
||||||
|
> `docker compose up -d`, neu anmelden — dann greift wieder die Regel „der
|
||||||
|
> erste wird Administrator“, sofern es noch gar keinen gibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was die Anwendung aus Authentik übernimmt — und was nicht
|
||||||
|
|
||||||
|
| Übernommen | Bleibt in der Anwendung |
|
||||||
|
| --- | --- |
|
||||||
|
| Wer jemand ist (`sub`) | Mitgliedschaft in Gruppen |
|
||||||
|
| E-Mail-Adresse | Rolle je Gruppe (Admin/Bearbeiter/Betrachter) |
|
||||||
|
| Anzeigename | Zuteilung von Domains |
|
||||||
|
| Instanz-Administration, falls `OIDC_ADMIN_GROUP` gesetzt ist | alles Übrige |
|
||||||
|
|
||||||
|
Das ist eine bewusste Entscheidung. Die Gruppen in Authentik und die Gruppen
|
||||||
|
hier bedeuten nicht dasselbe: dort geht es um Organisation und Zugang, hier um
|
||||||
|
die Frage, wer welche QR-Codes sehen und ändern darf. Eine automatische
|
||||||
|
Übersetzung wäre eine Quelle stiller Überraschungen — deshalb pflegt man
|
||||||
|
Gruppen und Rollen weiterhin unter **Verwaltung → Gruppen**.
|
||||||
|
|
||||||
|
Wiedererkannt wird jemand an der **Kennung von Authentik** (`sub`), nicht an
|
||||||
|
der E-Mail-Adresse. Ändert sich die Adresse, bleibt es dasselbe Konto.
|
||||||
|
|
||||||
|
### Die Admin-Gruppe
|
||||||
|
|
||||||
|
Steht in `OIDC_ADMIN_GROUP` ein Gruppenname, entscheidet Authentik bei **jeder
|
||||||
|
Anmeldung** neu, wer die Verwaltung öffnen darf: Mitglieder der Gruppe
|
||||||
|
bekommen das Recht, alle anderen verlieren es. Bleibt die Variable leer,
|
||||||
|
rührt die Anmeldung das Recht nicht an — dann pflegt man es hier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bestehende Konten übernehmen
|
||||||
|
|
||||||
|
Gibt es hier schon Konten mit Passwort, werden sie beim ersten Anmelden über
|
||||||
|
Authentik **anhand der E-Mail-Adresse verknüpft**. Aus dem lokalen
|
||||||
|
Administrator wird also kein zweites Konto; Gruppen, Rollen und die
|
||||||
|
angelegten Codes bleiben.
|
||||||
|
|
||||||
|
Verknüpft wird nur, wenn Authentik die Adresse als **bestätigt** meldet. Sonst
|
||||||
|
könnte sich beim Anbieter jemand eine fremde Adresse eintragen und damit ein
|
||||||
|
hiesiges Konto übernehmen. Meldet Authentik sie als unbestätigt, bricht die
|
||||||
|
Anmeldung mit einer Erklärung ab.
|
||||||
|
|
||||||
|
Ist ein Konto einmal verknüpft, gibt es dafür kein Passwort mehr: Der Bereich
|
||||||
|
**Konto** zeigt dann statt der Passwortänderung einen Hinweis auf den
|
||||||
|
Anbieter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nur noch über Authentik anmelden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OIDC_ONLY=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach verschwindet das Passwortfeld von der Anmeldeseite.
|
||||||
|
|
||||||
|
> **Vorher an den Notausgang denken.** Fällt Authentik aus oder ist der
|
||||||
|
> Provider falsch konfiguriert, kommt niemand mehr hinein. Der Weg zurück:
|
||||||
|
> `OIDC_ONLY=false` in der `.env`, `docker compose up -d` — dann geht die
|
||||||
|
> Anmeldung mit Passwort wieder. Lege dir dafür ein lokales Administratorkonto
|
||||||
|
> an, **bevor** du umstellst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht klappt
|
||||||
|
|
||||||
|
| Symptom | Ursache und Abhilfe |
|
||||||
|
| --- | --- |
|
||||||
|
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
|
||||||
|
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
|
||||||
|
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
|
||||||
|
| Authentik meldet `redirect_uri mismatch` | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein. Auf Schema, Hostname und Schrägstriche achten. |
|
||||||
|
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
|
||||||
|
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
|
||||||
|
| „Der Anmeldevorgang ist abgelaufen“ | Zwischen Klick und Rückkehr sind mehr als 10 Minuten vergangen, oder der Cookie ging verloren. Häufigste echte Ursache: `BASE_URL` zeigt auf eine andere Adresse als die, über die der Browser kommt. |
|
||||||
|
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
|
||||||
|
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
|
||||||
|
|
||||||
|
Ausführlichere Meldungen stehen im Protokoll:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was technisch passiert
|
||||||
|
|
||||||
|
1. Klick auf den Knopf → `/api/auth/oidc/start`. Die Anwendung holt das
|
||||||
|
Discovery-Dokument, würfelt `state`, `nonce` und ein PKCE-Geheimnis und legt
|
||||||
|
sie in einen kurzlebigen, `httpOnly`-Cookie.
|
||||||
|
2. Weiterleitung zu Authentik mit `code_challenge` (S256).
|
||||||
|
3. Nach der Anmeldung kommt Authentik mit `code` und `state` zurück auf
|
||||||
|
`/api/auth/oidc/callback`. Stimmt der `state` nicht, endet es hier.
|
||||||
|
4. Die Anwendung tauscht den Code gegen Token — mit Client-Geheimnis **und**
|
||||||
|
PKCE-Verifier. Ein abgefangener Code allein nützt also niemandem.
|
||||||
|
5. Das ID-Token wird gegen den JWKS von Authentik geprüft: Signatur,
|
||||||
|
Aussteller, Empfänger, Ablauf und `nonce`.
|
||||||
|
6. Konto suchen (über `sub`, ersatzweise über die bestätigte Adresse), anlegen
|
||||||
|
oder verknüpfen — und die Sitzung wie bei der Passwort-Anmeldung setzen.
|
||||||
|
|
||||||
|
Die Sitzung selbst ist danach dieselbe wie sonst: ein Zufallstoken im Cookie,
|
||||||
|
in der Datenbank nur dessen SHA-256. Abmelden beendet sie hier; bei Authentik
|
||||||
|
bleibt man angemeldet.
|
||||||
Generated
+12
-2
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"jose": "^6.2.8",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -5950,6 +5951,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
|
||||||
|
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
+1
-1
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
|
"jose": "^6.2.8",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -44,4 +45,3 @@
|
|||||||
"unrs-resolver": true
|
"unrs-resolver": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Anmeldung über einen externen Anbieter (OpenID Connect).
|
||||||
|
--
|
||||||
|
-- `oidcSubject` ist die stabile Kennung beim Anbieter. Sie und nicht die
|
||||||
|
-- E-Mail-Adresse entscheidet, wer wiedererkannt wird — Adressen ändern sich.
|
||||||
|
ALTER TABLE "User" ADD COLUMN "oidcSubject" TEXT;
|
||||||
|
CREATE UNIQUE INDEX "User_oidcSubject_key" ON "User"("oidcSubject");
|
||||||
|
|
||||||
|
-- Konten, die sich nur über den Anbieter anmelden, haben kein Passwort.
|
||||||
|
ALTER TABLE "User" ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||||
@@ -29,7 +29,12 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
||||||
passwordHash String
|
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
|
||||||
|
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
|
||||||
|
passwordHash String?
|
||||||
|
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
|
||||||
|
/// sich das Konto nur mit Passwort anmeldet.
|
||||||
|
oidcSubject String? @unique
|
||||||
/// Darf Gruppen, Benutzer und Domains verwalten.
|
/// Darf Gruppen, Benutzer und Domains verwalten.
|
||||||
isInstanceAdmin Boolean @default(false)
|
isInstanceAdmin Boolean @default(false)
|
||||||
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { LoginForm, SetupForm } from "@/components/auth-forms";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Notice } from "@/components/ui";
|
import { Notice } from "@/components/ui";
|
||||||
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor";
|
||||||
|
import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc";
|
||||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Anmelden" };
|
export const metadata: Metadata = { title: "Anmelden" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SignInPage() {
|
type PageProps = { searchParams: Promise<{ fehler?: string }> };
|
||||||
|
|
||||||
|
export default async function SignInPage({ searchParams }: PageProps) {
|
||||||
// Ohne Anmeldung gibt es hier nichts zu tun.
|
// Ohne Anmeldung gibt es hier nichts zu tun.
|
||||||
if (!isAuthEnabled()) redirect("/");
|
if (!isAuthEnabled()) redirect("/");
|
||||||
|
|
||||||
@@ -18,30 +21,85 @@ export default async function SignInPage() {
|
|||||||
if (actor.kind === "user") redirect("/");
|
if (actor.kind === "user") redirect("/");
|
||||||
|
|
||||||
const setup = await isSetupOpen();
|
const setup = await isSetupOpen();
|
||||||
|
const { fehler } = await searchParams;
|
||||||
|
|
||||||
|
// Eine unvollständige OIDC-Konfiguration soll die Anmeldeseite nicht
|
||||||
|
// unbenutzbar machen — dann bleibt wenigstens das Passwortfeld.
|
||||||
|
let oidc: { label: string; nurOidc: boolean } | null = null;
|
||||||
|
let oidcFehler: string | null = null;
|
||||||
|
if (isOidcEnabled()) {
|
||||||
|
try {
|
||||||
|
const config = getOidcConfig();
|
||||||
|
oidc = { label: config.buttonLabel, nurOidc: !config.allowLocalLogin };
|
||||||
|
} catch (error) {
|
||||||
|
oidcFehler =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Die Anmeldung über den externen Anbieter ist nicht eingerichtet.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei reiner Anbieter-Anmeldung gibt es keine Ersteinrichtung mit Passwort:
|
||||||
|
// Der erste, der sich über den Anbieter anmeldet, wird Administrator.
|
||||||
|
const zeigeFormular = !oidc?.nurOidc;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
|
<div className="mx-auto flex max-w-md flex-col gap-6 px-4 py-14 sm:px-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
|
<span className="mx-auto flex size-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
|
||||||
<Icon name={setup ? "lock" : "qr"} className="size-6" />
|
<Icon name={setup && zeigeFormular ? "lock" : "qr"} className="size-6" />
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
<h1 className="mt-4 text-2xl font-bold tracking-tight text-slate-900">
|
||||||
{setup ? "Ersteinrichtung" : "Anmelden"}
|
{setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
{setup
|
{setup && zeigeFormular
|
||||||
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
|
? "Es gibt noch keinen Benutzer. Lege jetzt den ersten Administrator an — danach ist diese Seite geschlossen."
|
||||||
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
|
: "Bitte melde dich an, um QR-Codes zu erstellen und zu verwalten."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{setup ? <SetupForm minLength={MIN_PASSWORD_LENGTH} /> : <LoginForm />}
|
{fehler ? <Notice tone="warning">{fehler}</Notice> : null}
|
||||||
|
{oidcFehler ? <Notice tone="warning">{oidcFehler}</Notice> : null}
|
||||||
|
|
||||||
{setup ? (
|
{oidc ? (
|
||||||
|
<a
|
||||||
|
href="/api/auth/oidc/start"
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<Icon name="lock" className="size-4" />
|
||||||
|
{oidc.label}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{oidc && zeigeFormular ? (
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-400 uppercase">
|
||||||
|
<span className="h-px flex-1 bg-slate-200" />
|
||||||
|
oder mit Passwort
|
||||||
|
<span className="h-px flex-1 bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{zeigeFormular ? (
|
||||||
|
setup ? (
|
||||||
|
<SetupForm minLength={MIN_PASSWORD_LENGTH} />
|
||||||
|
) : (
|
||||||
|
<LoginForm />
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{setup && zeigeFormular ? (
|
||||||
<Notice tone="info">
|
<Notice tone="info">
|
||||||
Der erste Benutzer wird Administrator der Installation und darf
|
Der erste Benutzer wird Administrator der Installation und darf
|
||||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
) : oidc?.nurOidc ? (
|
||||||
|
<p className="text-center text-sm text-slate-500">
|
||||||
|
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
|
||||||
|
{setup
|
||||||
|
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-sm text-slate-500">
|
<p className="text-center text-sm text-slate-500">
|
||||||
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isAuthEnabled } from "@/lib/auth/actor";
|
|||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password";
|
||||||
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
|
import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Konto" };
|
export const metadata: Metadata = { title: "Konto" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,6 +15,15 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function AccountPage() {
|
export default async function AccountPage() {
|
||||||
const actor = await requirePageActor();
|
const actor = await requirePageActor();
|
||||||
|
|
||||||
|
// Konten aus dem externen Verzeichnis haben hier kein Passwort.
|
||||||
|
const ausVerzeichnis =
|
||||||
|
actor.kind === "user"
|
||||||
|
? ((await prisma.user.findUnique({
|
||||||
|
where: { id: actor.user.id },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
}))?.passwordHash ?? null) === null
|
||||||
|
: false;
|
||||||
|
|
||||||
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
|
// Bei ausgeschalteter Anmeldung gibt es kein Konto — dann erklärt die Seite,
|
||||||
// wie sich das umstellen lässt, statt einfach leer zu sein.
|
// wie sich das umstellen lässt, statt einfach leer zu sein.
|
||||||
if (actor.kind !== "user") {
|
if (actor.kind !== "user") {
|
||||||
@@ -123,7 +133,17 @@ export default async function AccountPage() {
|
|||||||
|
|
||||||
{isAuthEnabled() ? (
|
{isAuthEnabled() ? (
|
||||||
<>
|
<>
|
||||||
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
{ausVerzeichnis ? (
|
||||||
|
<Card>
|
||||||
|
<CardTitle>Passwort</CardTitle>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Dieses Konto meldet sich über den externen Anbieter an. Ein
|
||||||
|
Passwort gibt es hier nicht — geändert wird es beim Anbieter.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ChangePasswordForm minLength={MIN_PASSWORD_LENGTH} />
|
||||||
|
)}
|
||||||
<SignOutCard />
|
<SignOutCard />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||||
|
import {
|
||||||
|
exchangeCode,
|
||||||
|
getOidcConfig,
|
||||||
|
isOidcEnabled,
|
||||||
|
OIDC_FLOW_COOKIE,
|
||||||
|
type OidcConfig,
|
||||||
|
type OidcIdentity,
|
||||||
|
} from "@/lib/auth/oidc";
|
||||||
|
import { createSession } from "@/lib/auth/session";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getBaseUrl } from "@/lib/env";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Zurück zur Anmeldeseite, mit einer Meldung, die dort angezeigt wird. */
|
||||||
|
function zurueckMitFehler(meldung: string): Response {
|
||||||
|
const ziel = new URL("/anmelden", getBaseUrl());
|
||||||
|
ziel.searchParams.set("fehler", meldung);
|
||||||
|
return Response.redirect(ziel.toString(), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlowState = { state: string; nonce: string; codeVerifier: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt Fehler, deren Meldung an den Browser darf. Wie in `lib/http.ts`
|
||||||
|
* über eine Eigenschaft statt `instanceof` — die Fehlerklassen landen in
|
||||||
|
* mehreren Bündeln und hätten dort verschiedene Identitäten.
|
||||||
|
*/
|
||||||
|
function istBenutzerfehler(error: unknown): error is Error {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error as Error & { userFacing?: unknown }).userFacing === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFlow(raw: string | undefined): FlowState | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const wert = JSON.parse(raw) as Partial<FlowState>;
|
||||||
|
if (
|
||||||
|
typeof wert.state === "string" &&
|
||||||
|
typeof wert.nonce === "string" &&
|
||||||
|
typeof wert.codeVerifier === "string"
|
||||||
|
) {
|
||||||
|
return wert as FlowState;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unbrauchbarer Cookie — behandeln wie keiner.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt das Konto an oder findet es wieder.
|
||||||
|
*
|
||||||
|
* Wiedererkannt wird über die Kennung des Anbieters (`sub`), nicht über die
|
||||||
|
* E-Mail-Adresse — Adressen ändern sich, die Kennung nicht. Ein bestehendes
|
||||||
|
* Konto mit derselben Adresse wird beim ersten Mal verknüpft, damit aus einem
|
||||||
|
* lokal angelegten Administrator nicht plötzlich ein zweites Konto wird.
|
||||||
|
*/
|
||||||
|
async function findeOderLegeAn(
|
||||||
|
identity: OidcIdentity,
|
||||||
|
config: OidcConfig,
|
||||||
|
): Promise<{ id: string; isActive: boolean } | { fehler: string }> {
|
||||||
|
const nachSubject = await prisma.user.findUnique({
|
||||||
|
where: { oidcSubject: identity.subject },
|
||||||
|
});
|
||||||
|
|
||||||
|
const istAdmin = config.adminGroup
|
||||||
|
? identity.groups.includes(config.adminGroup)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (nachSubject) {
|
||||||
|
const aktualisiert = await prisma.user.update({
|
||||||
|
where: { id: nachSubject.id },
|
||||||
|
data: {
|
||||||
|
email: identity.email,
|
||||||
|
name: identity.name ?? nachSubject.name,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
failedLogins: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
// Nur wenn eine Admin-Gruppe konfiguriert ist, entscheidet der
|
||||||
|
// Anbieter darüber. Sonst bleibt es bei dem, was hier eingestellt ist.
|
||||||
|
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: aktualisiert.id, isActive: aktualisiert.isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachEmail = await prisma.user.findUnique({
|
||||||
|
where: { email: identity.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nachEmail) {
|
||||||
|
// Verknüpfen nur bei bestätigter Adresse: Sonst könnte sich beim Anbieter
|
||||||
|
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||||
|
// übernehmen.
|
||||||
|
if (!identity.emailVerified) {
|
||||||
|
return {
|
||||||
|
fehler:
|
||||||
|
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const verknuepft = await prisma.user.update({
|
||||||
|
where: { id: nachEmail.id },
|
||||||
|
data: {
|
||||||
|
oidcSubject: identity.subject,
|
||||||
|
name: identity.name ?? nachEmail.name,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
failedLogins: 0,
|
||||||
|
lockedUntil: null,
|
||||||
|
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: verknuepft.id, isActive: verknuepft.isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neues Konto. Ohne Passwort — angemeldet wird über den Anbieter.
|
||||||
|
const standardgruppe = await prisma.group.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
||||||
|
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
||||||
|
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
||||||
|
const istErster = (await prisma.user.count()) === 0;
|
||||||
|
|
||||||
|
const angelegt = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: identity.email,
|
||||||
|
name: identity.name,
|
||||||
|
passwordHash: null,
|
||||||
|
oidcSubject: identity.subject,
|
||||||
|
isInstanceAdmin: istAdmin ?? istErster,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
memberships: standardgruppe
|
||||||
|
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
|
const store = await cookies();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isAuthEnabled() || !isOidcEnabled()) {
|
||||||
|
return zurueckMitFehler("Die Anmeldung über den Anbieter ist ausgeschaltet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// Der Anbieter meldet einen Abbruch oder eine verweigerte Freigabe.
|
||||||
|
const fehler = params.get("error");
|
||||||
|
if (fehler) {
|
||||||
|
const beschreibung = params.get("error_description");
|
||||||
|
console.error("OIDC: Anbieter meldet Fehler:", fehler, beschreibung);
|
||||||
|
return zurueckMitFehler(
|
||||||
|
fehler === "access_denied"
|
||||||
|
? "Der Anbieter hat die Anmeldung abgelehnt. Ist dein Konto für diese Anwendung freigegeben?"
|
||||||
|
: "Die Anmeldung beim Anbieter ist fehlgeschlagen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = readFlow(store.get(OIDC_FLOW_COOKIE)?.value);
|
||||||
|
if (!flow) {
|
||||||
|
return zurueckMitFehler(
|
||||||
|
"Der Anmeldevorgang ist abgelaufen. Bitte noch einmal beginnen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = params.get("code");
|
||||||
|
const state = params.get("state");
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
return zurueckMitFehler("Die Antwort des Anbieters war unvollständig.");
|
||||||
|
}
|
||||||
|
if (state !== flow.state) {
|
||||||
|
// Klassischer CSRF-Schutz: Die Antwort gehört nicht zu diesem Aufruf.
|
||||||
|
return zurueckMitFehler(
|
||||||
|
"Die Antwort des Anbieters passt nicht zu diesem Anmeldevorgang.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getOidcConfig();
|
||||||
|
const identity = await exchangeCode({
|
||||||
|
config,
|
||||||
|
code,
|
||||||
|
codeVerifier: flow.codeVerifier,
|
||||||
|
nonce: flow.nonce,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = await findeOderLegeAn(identity, config);
|
||||||
|
if ("fehler" in ergebnis) {
|
||||||
|
return zurueckMitFehler(ergebnis.fehler);
|
||||||
|
}
|
||||||
|
if (!ergebnis.isActive) {
|
||||||
|
return zurueckMitFehler(
|
||||||
|
"Dieses Konto ist in der Anwendung deaktiviert. Bitte wende dich an einen Administrator.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSession(ergebnis.id);
|
||||||
|
|
||||||
|
const ziel = new URL("/", getBaseUrl());
|
||||||
|
return Response.redirect(ziel.toString(), 302);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OIDC: Rückmeldung konnte nicht verarbeitet werden:", error);
|
||||||
|
// Nur Meldungen, die ausdrücklich für die Oberfläche gedacht sind —
|
||||||
|
// alles andere könnte Interna verraten.
|
||||||
|
const meldung = istBenutzerfehler(error)
|
||||||
|
? error.message
|
||||||
|
: "Die Anmeldung ist fehlgeschlagen.";
|
||||||
|
return zurueckMitFehler(meldung);
|
||||||
|
} finally {
|
||||||
|
// In jedem Fall aufräumen — der Cookie ist nur für einen Durchlauf gut.
|
||||||
|
store.delete(OIDC_FLOW_COOKIE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||||
|
import {
|
||||||
|
discover,
|
||||||
|
getOidcConfig,
|
||||||
|
isOidcEnabled,
|
||||||
|
OIDC_FLOW_COOKIE,
|
||||||
|
OIDC_FLOW_MINUTES,
|
||||||
|
redirectUri,
|
||||||
|
} from "@/lib/auth/oidc";
|
||||||
|
import { errorResponse } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function base64url(buffer: Buffer): string {
|
||||||
|
return buffer.toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt den Browser zum Anbieter.
|
||||||
|
*
|
||||||
|
* Mit PKCE (RFC 7636): Der Code allein nützt niemandem, weil beim Einlösen
|
||||||
|
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
|
||||||
|
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
|
||||||
|
*/
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
if (!isAuthEnabled()) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung ist ausgeschaltet (AUTH_ENABLED=false).",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isOidcEnabled()) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung über einen externen Anbieter ist ausgeschaltet (OIDC_ENABLED=false).",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getOidcConfig();
|
||||||
|
const meta = await discover(config.issuer);
|
||||||
|
|
||||||
|
const state = base64url(randomBytes(32));
|
||||||
|
const nonce = base64url(randomBytes(32));
|
||||||
|
const codeVerifier = base64url(randomBytes(32));
|
||||||
|
const codeChallenge = base64url(
|
||||||
|
createHash("sha256").update(codeVerifier).digest(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(OIDC_FLOW_COOKIE, JSON.stringify({ state, nonce, codeVerifier }), {
|
||||||
|
httpOnly: true,
|
||||||
|
// `lax` genügt: Der Anbieter schickt per GET zurück, dabei wird der
|
||||||
|
// Cookie mitgeschickt.
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: (process.env.BASE_URL ?? "").startsWith("https://"),
|
||||||
|
maxAge: OIDC_FLOW_MINUTES * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ziel = new URL(meta.authorization_endpoint);
|
||||||
|
ziel.searchParams.set("response_type", "code");
|
||||||
|
ziel.searchParams.set("client_id", config.clientId);
|
||||||
|
ziel.searchParams.set("redirect_uri", redirectUri());
|
||||||
|
ziel.searchParams.set("scope", config.scopes);
|
||||||
|
ziel.searchParams.set("state", state);
|
||||||
|
ziel.searchParams.set("nonce", nonce);
|
||||||
|
ziel.searchParams.set("code_challenge", codeChallenge);
|
||||||
|
ziel.searchParams.set("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
return Response.redirect(ziel.toString(), 302);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,15 @@ async function handleChangePassword(
|
|||||||
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
throw new QrRequestError("Dieses Konto existiert nicht mehr.", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Konten aus dem externen Verzeichnis haben hier kein Passwort — das
|
||||||
|
// wird beim Anbieter gewechselt, nicht hier.
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Dieses Konto meldet sich über den externen Anbieter an. Das Passwort wird dort geändert.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
if (!(await verifyPassword(input.currentPassword, user.passwordHash))) {
|
||||||
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
throw new QrRequestError("Das bisherige Passwort stimmt nicht.", 403);
|
||||||
}
|
}
|
||||||
@@ -227,6 +236,13 @@ async function handleLogin(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Dieses Konto meldet sich über den externen Anbieter an. Bitte den Knopf oben benutzen.",
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||||
const failedLogins = user.failedLogins + 1;
|
const failedLogins = user.failedLogins + 1;
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
|
||||||
|
import { getBaseUrl } from "@/lib/env";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung über einen externen Anbieter (OpenID Connect) — gedacht und
|
||||||
|
* getestet für Authentik, aber nicht darauf festgelegt: Alles Nötige kommt
|
||||||
|
* aus dem Discovery-Dokument des Anbieters.
|
||||||
|
*
|
||||||
|
* Bewusst begrenzt auf die Frage „wer bist du?“. Wer in welcher Gruppe welche
|
||||||
|
* Rolle hat, bleibt Sache der Verwaltung dieser Anwendung — die Rollen hier
|
||||||
|
* bedeuten etwas anderes als die Gruppen im Verzeichnis, und eine automatische
|
||||||
|
* Übersetzung wäre eine Quelle stiller Überraschungen. Einzige Ausnahme ist
|
||||||
|
* die Instanz-Administration, die sich über `OIDC_ADMIN_GROUP` an eine Gruppe
|
||||||
|
* beim Anbieter hängen lässt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type OidcConfig = {
|
||||||
|
issuer: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
/** Anzeigename des Knopfs auf der Anmeldeseite. */
|
||||||
|
buttonLabel: string;
|
||||||
|
/** Gruppe beim Anbieter, die zur Instanz-Administration berechtigt. */
|
||||||
|
adminGroup: string | null;
|
||||||
|
/** Name des Anspruchs, in dem die Gruppen stehen. */
|
||||||
|
groupsClaim: string;
|
||||||
|
/** Rolle, die neu angelegte Benutzer in der Standardgruppe bekommen. */
|
||||||
|
defaultRole: "ADMIN" | "EDITOR" | "VIEWER";
|
||||||
|
/** Ist die Anmeldung mit Passwort daneben noch erlaubt? */
|
||||||
|
allowLocalLogin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function env(name: string): string {
|
||||||
|
return process.env[name]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie, der State, Nonce und PKCE-Geheimnis über die Weiterleitung zum
|
||||||
|
* Anbieter trägt. Lebt nur für die Dauer des Anmeldevorgangs.
|
||||||
|
*/
|
||||||
|
export const OIDC_FLOW_COOKIE = "qrgen_oidc_flow";
|
||||||
|
export const OIDC_FLOW_MINUTES = 10;
|
||||||
|
|
||||||
|
export function isOidcEnabled(): boolean {
|
||||||
|
return env("OIDC_ENABLED").toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Konfiguration. Wirft mit einer Meldung, die sagt, was fehlt —
|
||||||
|
* eine halb eingerichtete Anmeldung soll nicht als „irgendwas ging schief“
|
||||||
|
* enden.
|
||||||
|
*/
|
||||||
|
export function getOidcConfig(): OidcConfig {
|
||||||
|
const issuer = env("OIDC_ISSUER").replace(/\/+$/, "");
|
||||||
|
const clientId = env("OIDC_CLIENT_ID");
|
||||||
|
const clientSecret = env("OIDC_CLIENT_SECRET");
|
||||||
|
|
||||||
|
const fehlend = [
|
||||||
|
!issuer && "OIDC_ISSUER",
|
||||||
|
!clientId && "OIDC_CLIENT_ID",
|
||||||
|
!clientSecret && "OIDC_CLIENT_SECRET",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (fehlend.length > 0) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet. Es fehlt: ${fehlend.join(", ")}.`,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolle = env("OIDC_DEFAULT_ROLE").toUpperCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
scopes: env("OIDC_SCOPES") || "openid email profile",
|
||||||
|
buttonLabel: env("OIDC_BUTTON_LABEL") || "Mit Authentik anmelden",
|
||||||
|
adminGroup: env("OIDC_ADMIN_GROUP") || null,
|
||||||
|
groupsClaim: env("OIDC_GROUPS_CLAIM") || "groups",
|
||||||
|
defaultRole:
|
||||||
|
rolle === "ADMIN" || rolle === "EDITOR" || rolle === "VIEWER"
|
||||||
|
? rolle
|
||||||
|
: "VIEWER",
|
||||||
|
allowLocalLogin: env("OIDC_ONLY").toLowerCase() !== "true",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse, an die der Anbieter nach der Anmeldung zurückschickt. */
|
||||||
|
export function redirectUri(): string {
|
||||||
|
return `${getBaseUrl()}/api/auth/oidc/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Discovery = {
|
||||||
|
issuer: string;
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
jwks_uri: string;
|
||||||
|
userinfo_endpoint?: string;
|
||||||
|
end_session_endpoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Discovery-Dokument ändert sich praktisch nie, der Abruf kostet aber bei
|
||||||
|
* jeder Anmeldung eine Runde. Deshalb im Speicher halten — eine Stunde ist
|
||||||
|
* kurz genug, um einen Schlüsselwechsel mitzubekommen.
|
||||||
|
*/
|
||||||
|
const DISCOVERY_TTL_MS = 60 * 60 * 1000;
|
||||||
|
let discoveryCache: { url: string; bis: number; wert: Discovery } | null = null;
|
||||||
|
|
||||||
|
export async function discover(issuer: string): Promise<Discovery> {
|
||||||
|
const url = `${issuer}/.well-known/openid-configuration`;
|
||||||
|
const jetzt = Date.now();
|
||||||
|
|
||||||
|
if (discoveryCache && discoveryCache.url === url && discoveryCache.bis > jetzt) {
|
||||||
|
return discoveryCache.wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der Anmelde-Anbieter ist nicht erreichbar (${issuer}). Läuft er, und stimmt OIDC_ISSUER?`,
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der Anmelde-Anbieter liefert unter ${url} den Status ${response.status}. Stimmt OIDC_ISSUER?`,
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wert = (await response.json()) as Discovery;
|
||||||
|
if (!wert.authorization_endpoint || !wert.token_endpoint || !wert.jwks_uri) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Anmelde-Anbieter liefert ein unvollständiges Discovery-Dokument.",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryCache = { url, bis: jetzt + DISCOVERY_TTL_MS, wert };
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JWKS je Adresse zwischenspeichern — `jose` erneuert bei Bedarf selbst. */
|
||||||
|
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||||
|
|
||||||
|
function jwks(uri: string) {
|
||||||
|
const vorhanden = jwksCache.get(uri);
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
const neu = createRemoteJWKSet(new URL(uri));
|
||||||
|
jwksCache.set(uri, neu);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OidcIdentity = {
|
||||||
|
subject: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
name: string | null;
|
||||||
|
groups: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function readGroups(payload: JWTPayload, claim: string): string[] {
|
||||||
|
const wert = payload[claim];
|
||||||
|
if (Array.isArray(wert)) {
|
||||||
|
return wert.filter((eintrag): eintrag is string => typeof eintrag === "string");
|
||||||
|
}
|
||||||
|
// Manche Anbieter liefern eine durch Leerzeichen getrennte Liste.
|
||||||
|
if (typeof wert === "string") return wert.split(/\s+/).filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tauscht den Code gegen Token und prüft das ID-Token.
|
||||||
|
*
|
||||||
|
* Geprüft wird die Signatur gegen den JWKS des Anbieters sowie Aussteller,
|
||||||
|
* Empfänger und die Nonce. Letztere schließt aus, dass ein anderswo
|
||||||
|
* erbeutetes Token hier eingereicht wird.
|
||||||
|
*/
|
||||||
|
export async function exchangeCode(options: {
|
||||||
|
config: OidcConfig;
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
nonce: string;
|
||||||
|
}): Promise<OidcIdentity> {
|
||||||
|
const { config, code, codeVerifier, nonce } = options;
|
||||||
|
const meta = await discover(config.issuer);
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri(),
|
||||||
|
client_id: config.clientId,
|
||||||
|
client_secret: config.clientSecret,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(meta.token_endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Der Text des Anbieters ist für Betreiber oft hilfreich, gehört aber
|
||||||
|
// nicht ungefiltert in den Browser.
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
console.error("OIDC: Token-Tausch fehlgeschlagen:", response.status, text);
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung beim Anbieter ist fehlgeschlagen. Stimmen Client-ID und Geheimnis?",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = (await response.json()) as { id_token?: string };
|
||||||
|
if (!token.id_token) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Anbieter hat kein ID-Token geliefert. Ist der Scope „openid“ erlaubt?",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: JWTPayload;
|
||||||
|
try {
|
||||||
|
({ payload } = await jwtVerify(token.id_token, jwks(meta.jwks_uri), {
|
||||||
|
issuer: meta.issuer || config.issuer,
|
||||||
|
audience: config.clientId,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("OIDC: ID-Token nicht gültig:", error);
|
||||||
|
throw new QrRequestError("Das Anmelde-Token des Anbieters ist ungültig.", 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.nonce !== nonce) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Anmeldung passt nicht zu dieser Sitzung. Bitte versuche es erneut.",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = typeof payload.sub === "string" ? payload.sub : "";
|
||||||
|
const email =
|
||||||
|
typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
throw new QrRequestError("Der Anbieter hat keine Kennung geliefert.", 502);
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Anbieter hat keine E-Mail-Adresse geliefert. Bitte den Scope „email“ freigeben und das Attribut mitgeben.",
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name =
|
||||||
|
typeof payload.name === "string" && payload.name.trim().length > 0
|
||||||
|
? payload.name.trim()
|
||||||
|
: typeof payload.preferred_username === "string"
|
||||||
|
? payload.preferred_username.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
email,
|
||||||
|
emailVerified: payload.email_verified !== false,
|
||||||
|
name,
|
||||||
|
groups: readGroups(payload, config.groupsClaim),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user