feat(auth): OpenID-Connect-SSO gegen Authentik (Backend + Web)

Zusaetzlicher Anmeldeweg per OIDC neben dem bestehenden Passwort-Login. Ohne
gesetztes OIDC_PROVIDERS aendert sich nichts: /api/auth/oidc/providers meldet
enabled:false und der Login-Screen bleibt unveraendert.

Backend
- oidc_config.py: Provider-Registry rein aus ENV. Ein zweiter Provider ist
  reine Konfiguration (OIDC_PROVIDERS + OIDC_<KEY>_* Block), keine Codeaenderung.
- oidc_client.py: Discovery, JWKS, Token-Tausch, ID-Token-Pruefung ueber
  authlib.jose mit fest gepinnter Signatur-Allowlist (nur asymmetrisch, damit
  alg:none und Algorithmus-Confusion ausgeschlossen sind).
- oidc_identity.py: Zuordnung ueber (provider_key, sub). Ein E-Mail-Treffer auf
  ein bestehendes Konto wird standardmaessig abgelehnt statt verknuepft — sonst
  erbt jeder, der im IdP eine beliebige E-Mail setzen kann, das passende Konto.
  Verknuepfen ist eine explizite, authentifizierte Aktion. Auto-Provisioning
  nur per ENV-Flag, standardmaessig aus, optional mit Domain-Allowlist.
- routers/oidc_router.py: /providers, /start, /callback, /complete, /exchange,
  /link, /identities. PKCE S256; Flow-State als kurzlebiges signiertes JWT in
  einem HttpOnly-Cookie mit jti==state statt eines prozesslokalen Dicts, damit
  der Flow Neustarts und mehrere Worker uebersteht. Das Calendarr-JWT wird ueber
  ein Einmal-Cookie uebergeben, nie ueber URL oder Fragment.
- /exchange als Kontrakt fuer die AppAuth-Clients: die App fuehrt den Flow als
  Public Client selbst aus und tauscht das ID-Token; kein Secret im Request.
- models.py: neue Tabelle oidc_identities, users.auth_source; Migration in
  main.py im dortigen try/except-Stil.

Nebenbefund, mitbehoben: auth.verify_password liess ValueError aus
bcrypt.checkpw durch. Ein nicht wohlgeformter password_hash wurde damit zu
HTTP 500 auf /api/auth/login und bei jedem CalDAV-Basic-Auth. SSO-Nutzer
bekommen einen echten bcrypt-Hash ueber ein zufaelliges, nirgends gespeichertes
Geheimnis; fuer CalDAV nutzen sie App-Passwoerter wie 2FA-Nutzer.

Web
- SSO-Block auf dem Login-Screen, aus /providers befuellt; Buttons sind Links,
  weil /start eine Top-Level-Navigation braucht.
- consumeSsoHandoff() laeuft vor den Setup-/Token-Pruefungen in boot() und
  raeumt die Query per replaceState ab, bevor es awaited.
- 401-Auto-Logout um die SSO-Pfade erweitert, sonst dreht ein fehlgeschlagener
  Handoff eine Reload-Schleife.
- i18n-Keys in de + en (Fallback-Kette deckt die uebrigen acht Sprachen).
- Service Worker laesst /api/auth/oidc/ unangetastet durch.

37 neue Tests (Gesamtsuite 60 gruen): Signaturpruefung mit echten RSA-Tokens,
alg:none, falscher iss/aud/nonce, unbekannte client_id, Provisioning-Policies,
Username-Kollision, Einmaligkeit des Handoff-Cookies, unerreichbarer Provider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-09 18:00:46 +02:00
co-authored by Claude Opus 5
parent a30375aa63
commit 31baf4b18c
17 changed files with 2008 additions and 5 deletions
+32
View File
@@ -21,6 +21,22 @@ const translations = {
login_tagline: 'das minimalistische Kalender Frontend',
login_submit: 'Anmelden',
login_2fa_ph: '6-stelliger Code',
// Single sign-on (OpenID Connect)
sso_or: 'oder',
sso_login_with: 'Mit {name} anmelden',
sso_err_generic: 'Anmeldung fehlgeschlagen',
sso_err_account_not_linked: 'Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.',
sso_err_signup_disabled: 'Automatische Kontoerstellung ist deaktiviert',
sso_err_domain_not_allowed: 'Diese E-Mail-Domain ist nicht zugelassen',
sso_err_email_conflict: 'Diese E-Mail-Adresse gehört bereits zu einem anderen Konto',
sso_err_email_not_verified: 'Die E-Mail-Adresse ist beim Anbieter nicht bestätigt',
sso_err_no_email: 'Der Anbieter hat keine E-Mail-Adresse übermittelt',
sso_err_state_mismatch: 'Sitzung abgelaufen, bitte erneut versuchen',
sso_err_provider_unreachable: 'Anmeldedienst nicht erreichbar, bitte später erneut versuchen',
sso_err_discovery_failed: 'Anmeldedienst antwortet nicht korrekt',
sso_err_setup_required: 'Bitte zuerst das Administrator-Konto einrichten',
sso_link_title: 'Single Sign-On',
sso_unlink: 'Verknüpfung aufheben',
// Topbar
btn_today: 'Heute',
@@ -809,6 +825,22 @@ const translations = {
login_tagline: 'the minimalist calendar frontend',
login_submit: 'Sign in',
login_2fa_ph: '6-digit code',
// Single sign-on (OpenID Connect)
sso_or: 'or',
sso_login_with: 'Sign in with {name}',
sso_err_generic: 'Sign-in failed',
sso_err_account_not_linked: 'No linked Calendarr account. Sign in locally and link it in Settings.',
sso_err_signup_disabled: 'Automatic account creation is disabled',
sso_err_domain_not_allowed: 'This email domain is not allowed',
sso_err_email_conflict: 'That email address already belongs to another account',
sso_err_email_not_verified: 'The email address is not verified at the provider',
sso_err_no_email: 'The provider did not supply an email address',
sso_err_state_mismatch: 'Session expired, please try again',
sso_err_provider_unreachable: 'Sign-in service unreachable, please try again later',
sso_err_discovery_failed: 'Sign-in service returned an invalid response',
sso_err_setup_required: 'Please set up the administrator account first',
sso_link_title: 'Single sign-on',
sso_unlink: 'Unlink',
// Topbar
btn_today: 'Today',