feat(auth): OIDC SSO for admin users — phase 1 (#798)

Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
This commit is contained in:
Paul Nothaft
2026-07-16 08:54:26 +02:00
parent f0cdcddb92
commit ed5fc5ad5c
17 changed files with 1507 additions and 18 deletions
+36
View File
@@ -2090,6 +2090,32 @@
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
},
"sso": {
"title": "Single Sign-On (OIDC)",
"intro": "Admins melden sich über Ihren Identity Provider an (Keycloak, Authentik, Pocket ID oder jeder OIDC-konforme IdP). Die lokale Anmeldung mit E-Mail/Passwort bleibt als Fallback verfügbar.",
"redirectUri": "Redirect-URI (beim IdP-Client registrieren)",
"issuerUrl": "Issuer-URL",
"issuerHint": "Die Basis-URL, die /.well-known/openid-configuration ausliefert.",
"clientId": "Client-ID",
"clientSecret": "Client-Secret",
"secretSetPlaceholder": "•••••• (gespeichert — zum Ersetzen tippen)",
"secretUnsetPlaceholder": "Client-Secret einfügen",
"secretHint": "Verschlüsselt gespeichert; wird nie wieder angezeigt. Leer lassen, um das aktuelle zu behalten.",
"scopes": "Scopes",
"autoprovision": "Unbekannte Benutzer automatisch anlegen",
"autoprovisionHint": "Erstellt beim ersten SSO-Login ein Admin-Konto. Aus: Nur bestehende/verknüpfte Admins können sich anmelden.",
"defaultRole": "Rolle für neue Benutzer",
"buttonLabel": "Beschriftung des Login-Buttons (optional)",
"buttonLabelPlaceholder": "Mit SSO anmelden",
"enabled": "SSO-Login aktivieren",
"enabledHint": "Zeigt den SSO-Button auf der Admin-Anmeldeseite. Erfordert Issuer, Client-ID und Secret.",
"test": "Verbindung testen",
"testHint": "Der Test führt OIDC-Discovery gegen die gespeicherte Konfiguration aus — zuerst speichern.",
"testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}",
"testFailed": "Discovery fehlgeschlagen",
"saved": "SSO-Einstellungen gespeichert",
"saveError": "SSO-Einstellungen konnten nicht gespeichert werden"
}
},
"branding": {
@@ -3706,6 +3732,16 @@
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
},
"ssoDivider": "oder",
"ssoSignIn": "Mit SSO anmelden",
"ssoErrors": {
"config": "SSO ist falsch konfiguriert — prüfen Sie die SSO-Einstellungen oder melden Sie sich mit E-Mail und Passwort an.",
"state": "Die SSO-Anmeldung ist abgelaufen oder wurde manipuliert. Bitte erneut versuchen.",
"idp": "Der Identity Provider hat die Anmeldung abgelehnt. Bitte erneut versuchen oder E-Mail und Passwort verwenden.",
"inactive": "Ihr Admin-Konto ist deaktiviert.",
"not_provisioned": "Kein Admin-Konto passt zu Ihrer SSO-Identität. Bitten Sie einen Administrator um eine Einladung.",
"no_email": "Ihr Identity Provider hat keine E-Mail-Adresse geliefert — es kann kein Konto erstellt werden."
}
},
"cssTemplates": {
+36
View File
@@ -1637,6 +1637,32 @@
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
},
"sso": {
"title": "Single Sign-On (OIDC)",
"intro": "Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.",
"redirectUri": "Redirect URI (register this on your IdP client)",
"issuerUrl": "Issuer URL",
"issuerHint": "The base URL that serves /.well-known/openid-configuration.",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"secretSetPlaceholder": "•••••• (saved — type to replace)",
"secretUnsetPlaceholder": "Paste the client secret",
"secretHint": "Stored encrypted; never shown again. Leave blank to keep the current one.",
"scopes": "Scopes",
"autoprovision": "Auto-provision unknown users",
"autoprovisionHint": "Create an admin account on first SSO login. Off: only existing/linked admins can sign in.",
"defaultRole": "Role for new users",
"buttonLabel": "Login button label (optional)",
"buttonLabelPlaceholder": "Sign in with SSO",
"enabled": "Enable SSO login",
"enabledHint": "Shows the SSO button on the admin login page. Requires issuer, client ID and secret.",
"test": "Test connection",
"testHint": "Test runs OIDC discovery against the saved configuration — save first.",
"testOk": "Discovery succeeded — issuer is reachable: {{issuer}}",
"testFailed": "Discovery failed",
"saved": "SSO settings saved",
"saveError": "Failed to save SSO settings"
}
},
"analytics": {
@@ -3595,6 +3621,16 @@
"sessionExpired": "Your verification session expired. Please sign in again.",
"locked": "Account temporarily locked due to too many attempts. Try again later.",
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
},
"ssoDivider": "or",
"ssoSignIn": "Sign in with SSO",
"ssoErrors": {
"config": "SSO is misconfigured — check the SSO settings or sign in with email and password.",
"state": "The SSO sign-in expired or was tampered with. Please try again.",
"idp": "The identity provider rejected the sign-in. Please try again or use email and password.",
"inactive": "Your admin account is deactivated.",
"not_provisioned": "No admin account matches your SSO identity. Ask an administrator to invite you.",
"no_email": "Your identity provider supplied no email address — an account cannot be created."
}
},
"slideshow": {