From c8d60d8006c911567636004871c11040b6f72c4b Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 15 Aug 2026 00:12:21 +0200 Subject: [PATCH] Anmeldung ueber Authentik und andere OIDC-Anbieter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 31 ++ README.md | 32 ++ docker-compose.yml | 10 + docs/authentik.md | 251 ++++++++++++++++ package-lock.json | 14 +- package.json | 2 +- .../20260814170000_oidc/migration.sql | 9 + prisma/schema.prisma | 7 +- src/app/(app)/anmelden/page.tsx | 70 ++++- src/app/(app)/konto/page.tsx | 22 +- src/app/api/auth/oidc/callback/route.ts | 226 ++++++++++++++ src/app/api/auth/oidc/start/route.ts | 79 +++++ src/app/api/auth/route.ts | 16 + src/lib/auth/oidc.ts | 281 ++++++++++++++++++ 14 files changed, 1039 insertions(+), 11 deletions(-) create mode 100644 docs/authentik.md create mode 100644 prisma/migrations/20260814170000_oidc/migration.sql create mode 100644 src/app/api/auth/oidc/callback/route.ts create mode 100644 src/app/api/auth/oidc/start/route.ts create mode 100644 src/lib/auth/oidc.ts diff --git a/.env.example b/.env.example index af46189..9e43a79 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,37 @@ AUTH_ENABLED=false # grosse Werte machen die Sicherung entsprechend schwer. 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// +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 ------------------------------------------------------------ POSTGRES_USER=qrcode POSTGRES_PASSWORD=bitte-aendern diff --git a/README.md b/README.md index 70e572c..9ba5bfe 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. - [Konfiguration](#konfiguration) - [Bedienung](#bedienung) - [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen) +- [Anmeldung über Authentik (OIDC)](#anmeldung-über-authentik-oidc) - [Technischer Aufbau](#technischer-aufbau) - [Entwicklung ohne Docker](#entwicklung-ohne-docker) - [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. --- +## 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 ### Stack diff --git a/docker-compose.yml b/docker-compose.yml index aebbee0..a1de962 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,16 @@ services: # Anmeldung mit Benutzern, Gruppen und Rollen. AUTH_ENABLED: ${AUTH_ENABLED:-false} 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: # Direktes Port-Mapping, bewusst ohne Reverse Proxy. - "${APP_PORT:-3000}:3000" diff --git a/docs/authentik.md b/docs/authentik.md new file mode 100644 index 0000000..175e251 --- /dev/null +++ b/docs/authentik.md @@ -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= +OIDC_CLIENT_SECRET= +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- /.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. diff --git a/package-lock.json b/package-lock.json index 4ad8623..ea1cd16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "qrcode-generator", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qrcode-generator", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", + "jose": "^6.2.8", "next": "16.3.0", "pg": "^8.16.3", "qrcode": "^1.5.4", @@ -5950,6 +5951,15 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 64a20cc..f838ccb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", + "jose": "^6.2.8", "next": "16.3.0", "pg": "^8.16.3", "qrcode": "^1.5.4", @@ -44,4 +45,3 @@ "unrs-resolver": true } } - diff --git a/prisma/migrations/20260814170000_oidc/migration.sql b/prisma/migrations/20260814170000_oidc/migration.sql new file mode 100644 index 0000000..62ec37a --- /dev/null +++ b/prisma/migrations/20260814170000_oidc/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63a3c71..3dd8599 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,7 +29,12 @@ model User { email String @unique name String? /// 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. isInstanceAdmin Boolean @default(false) /// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen diff --git a/src/app/(app)/anmelden/page.tsx b/src/app/(app)/anmelden/page.tsx index 876f845..e8017b5 100644 --- a/src/app/(app)/anmelden/page.tsx +++ b/src/app/(app)/anmelden/page.tsx @@ -5,12 +5,15 @@ import { LoginForm, SetupForm } from "@/components/auth-forms"; import { Icon } from "@/components/icons"; import { Notice } from "@/components/ui"; import { getActor, isAuthEnabled, isSetupOpen } from "@/lib/auth/actor"; +import { getOidcConfig, isOidcEnabled } from "@/lib/auth/oidc"; import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password"; export const metadata: Metadata = { title: "Anmelden" }; 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. if (!isAuthEnabled()) redirect("/"); @@ -18,30 +21,85 @@ export default async function SignInPage() { if (actor.kind === "user") redirect("/"); 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 (
- +

- {setup ? "Ersteinrichtung" : "Anmelden"} + {setup && zeigeFormular ? "Ersteinrichtung" : "Anmelden"}

- {setup + {setup && zeigeFormular ? "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."}

- {setup ? : } + {fehler ? {fehler} : null} + {oidcFehler ? {oidcFehler} : null} - {setup ? ( + {oidc ? ( + + + {oidc.label} + + ) : null} + + {oidc && zeigeFormular ? ( +
+ + oder mit Passwort + +
+ ) : null} + + {zeigeFormular ? ( + setup ? ( + + ) : ( + + ) + ) : null} + + {setup && zeigeFormular ? ( Der erste Benutzer wird Administrator der Installation und darf Gruppen, Benutzer und die QR-Typen verwalten. + ) : oidc?.nurOidc ? ( +

+ Die Anmeldung mit Passwort ist abgeschaltet (OIDC_ONLY=true). + {setup + ? " Wer sich zuerst anmeldet, wird Administrator dieser Installation." + : ""} +

) : (

Passwort vergessen? Ein Administrator kann es unter Verwaltung → diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx index f43b8c1..d8bbe13 100644 --- a/src/app/(app)/konto/page.tsx +++ b/src/app/(app)/konto/page.tsx @@ -7,6 +7,7 @@ import { isAuthEnabled } from "@/lib/auth/actor"; import { requirePageActor } from "@/lib/auth/guard"; import { MIN_PASSWORD_LENGTH } from "@/lib/auth/password"; import { ROLE_DESCRIPTIONS, ROLE_LABELS } from "@/lib/auth/policy"; +import { prisma } from "@/lib/db"; export const metadata: Metadata = { title: "Konto" }; export const dynamic = "force-dynamic"; @@ -14,6 +15,15 @@ export const dynamic = "force-dynamic"; export default async function AccountPage() { 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, // wie sich das umstellen lässt, statt einfach leer zu sein. if (actor.kind !== "user") { @@ -123,7 +133,17 @@ export default async function AccountPage() { {isAuthEnabled() ? ( <> - + {ausVerzeichnis ? ( + + Passwort +

+ Dieses Konto meldet sich über den externen Anbieter an. Ein + Passwort gibt es hier nicht — geändert wird es beim Anbieter. +

+ + ) : ( + + )} ) : null} diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts new file mode 100644 index 0000000..635ed9d --- /dev/null +++ b/src/app/api/auth/oidc/callback/route.ts @@ -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; + 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 { + 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); + } +} diff --git a/src/app/api/auth/oidc/start/route.ts b/src/app/api/auth/oidc/start/route.ts new file mode 100644 index 0000000..0595e61 --- /dev/null +++ b/src/app/api/auth/oidc/start/route.ts @@ -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 { + 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); + } +} diff --git a/src/app/api/auth/route.ts b/src/app/api/auth/route.ts index 88853d6..ecfdae7 100644 --- a/src/app/api/auth/route.ts +++ b/src/app/api/auth/route.ts @@ -140,6 +140,15 @@ async function handleChangePassword( 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))) { 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))) { const failedLogins = user.failedLogins + 1; await prisma.user.update({ diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts new file mode 100644 index 0000000..73c0a35 --- /dev/null +++ b/src/lib/auth/oidc.ts @@ -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 { + 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>(); + +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 { + 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), + }; +}