Files
Vorrania/backend/app/oidc_config.py
T
ScarriffleandClaude Opus 5 3d0ff54bf5 Anmeldung wahlweise ueber OpenID Connect (Authentik)
Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg:
OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration
aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher.

Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania
sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die
Rollenpruefung und die langlebigen API-Tokens unveraendert.

Backend
- Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein
  zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung.
- Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE)
  enden beide im selben Token-Schema.
- state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem
  Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit
  allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt.
  Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf
  bleibt mit der db-Fixture pruefbar.
- Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die
  BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein
  gleichlautender preferred_username uebernimmt nie ein bestehendes Konto.
- users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password
  faengt das ab, sonst antwortete der Login mit 500 statt 401.

Verknuepfen bestehender Konten
- Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen
  und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es
  braucht also weder eine hinterlegte Adresse noch Admin-Rechte.
- Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token
  nicht mitschicken kann.
- Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen.

iOS
- AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem
  eingebetteten WebView, und ohne Client-Secret.
- offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und
  bekaeme sonst einen ueberfluessigen Zustimmungsdialog.
- Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App
  still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung.

Deployment
- docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit
  fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht
  erfordern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:55:38 +02:00

193 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OIDC-Anmeldeanbieter aus Umgebungsvariablen lesen.
Ein Anbieter wird NICHT im Code hinterlegt, sondern über die Umgebung beschrieben.
``OIDC_PROVIDERS`` zaehlt die Kuerzel auf, jedes Kuerzel bekommt einen eigenen
Block::
OIDC_PROVIDERS=authentik
OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania/.well-known/openid-configuration
OIDC_AUTHENTIK_CLIENT_ID=...
OIDC_AUTHENTIK_CLIENT_SECRET=...
Ein zweiter Anbieter ist damit reine Konfiguration (``OIDC_PROVIDERS=authentik,keycloak``
plus zweiter Block) – ohne Codeaenderung. ``pydantic-settings`` kann solche
dynamischen Praefixe nicht, deshalb dieser eigene kleine Leser statt eines Feldes
in ``Settings``.
Fehlt die Konfiguration ganz, ist die Liste leer und die Anwendung laeuft wie
bisher rein mit Passwort-Login weiter. Das ist wichtig, weil ``deploy/update.sh``
bestehende ``.env``-Dateien nicht nachtraegt.
"""
from __future__ import annotations
import logging
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from functools import lru_cache
logger = logging.getLogger(__name__)
# Der Browser-Weg braucht KEIN Refresh-Token: das Backend loest den Code ein,
# stellt sein eigenes Vorrania-Token aus und wirft den Rest weg. offline_access
# hier anzufordern wuerde nur einen zusaetzlichen Zustimmungsdialog ausloesen.
STANDARD_SCOPES = "openid profile email"
# Die App dagegen haelt die Anmeldung selbst und erneuert sie im Hintergrund.
# offline_access ist dafuer Pflicht und kein Beiwerk: Authentik liefert seit
# 2024.2 ohne diesen Scope KEIN Refresh-Token.
STANDARD_NATIVE_SCOPES = "openid profile email offline_access"
# Custom-URL-Scheme der iOS-App. Der Scheme "vorrania" ist in der Info.plist
# bereits registriert (dort fuer vorrania://checkin und vorrania://i/<UID>).
STANDARD_NATIVE_REDIRECT = "vorrania://oauth/callback"
_ERLAUBTES_KUERZEL = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$|^[a-z0-9]$")
@dataclass(frozen=True)
class OidcProvider:
"""Ein konfigurierter Anmeldeanbieter."""
slug: str
display_name: str
discovery_url: str
client_id: str
# Nur der vertrauliche Web-Client hat ein Secret. Die App ist ein oeffentlicher
# Client und sendet nie eins – deshalb steht es hier auch nur fuer den Web-Weg.
client_secret: str = ""
scopes: str = STANDARD_SCOPES
native_scopes: str = STANDARD_NATIVE_SCOPES
# Client-IDs der nativen (oeffentlichen) Clients. Ein ID-Token aus der App
# traegt eine ANDERE aud als der Web-Client, deshalb eine eigene Liste.
native_client_ids: tuple[str, ...] = ()
native_redirect_uri: str = STANDARD_NATIVE_REDIRECT
redirect_uri: str = ""
auto_create: bool = True
# Verknuepfung mit einem bestehenden Konto nur bei bestaetigter E-Mail. Wer das
# abschaltet, erlaubt einem IdP-Konto mit frei gewaehlter Adresse die Uebernahme
# eines fremden Vorrania-Kontos.
require_verified_email: bool = True
@property
def erlaubte_audiences(self) -> tuple[str, ...]:
"""Alle aud-Werte, die ein ID-Token dieses Anbieters tragen darf."""
return (self.client_id, *self.native_client_ids)
def _wahrheitswert(rohwert: str | None, standard: bool) -> bool:
if rohwert is None or not rohwert.strip():
return standard
return rohwert.strip().lower() in {"1", "true", "yes", "on", "ja"}
def _liste(rohwert: str | None) -> tuple[str, ...]:
if not rohwert:
return ()
return tuple(t.strip() for t in rohwert.replace(";", ",").split(",") if t.strip())
def _praefix(slug: str) -> str:
""""authentik" -> "OIDC_AUTHENTIK_", "my-idp" -> "OIDC_MY_IDP_"."""
return "OIDC_" + slug.upper().replace("-", "_") + "_"
def provider_aus_umgebung(
env: Mapping[str, str], public_base_url: str = ""
) -> list[OidcProvider]:
"""Liest alle konfigurierten Anbieter.
Unvollstaendig konfigurierte Anbieter werden uebersprungen und protokolliert –
ein Tippfehler in der ``.env`` darf den Serverstart nicht verhindern.
"""
anbieter: list[OidcProvider] = []
basis = public_base_url.rstrip("/")
for roh in _liste(env.get("OIDC_PROVIDERS")):
slug = roh.strip().lower()
if not _ERLAUBTES_KUERZEL.match(slug):
logger.warning(
"OIDC-Anbieter '%s' uebersprungen: Kuerzel darf nur Kleinbuchstaben, "
"Ziffern und Bindestriche enthalten.",
roh,
)
continue
if any(a.slug == slug for a in anbieter):
logger.warning("OIDC-Anbieter '%s' doppelt genannt – zweiter ignoriert.", slug)
continue
p = _praefix(slug)
discovery = (env.get(p + "DISCOVERY_URL") or "").strip()
client_id = (env.get(p + "CLIENT_ID") or "").strip()
if not discovery or not client_id:
logger.warning(
"OIDC-Anbieter '%s' uebersprungen: %sDISCOVERY_URL und %sCLIENT_ID "
"muessen gesetzt sein.",
slug,
p,
p,
)
continue
redirect = (env.get(p + "REDIRECT_URI") or "").strip()
if not redirect:
if not basis:
logger.warning(
"OIDC-Anbieter '%s' uebersprungen: ohne PUBLIC_BASE_URL laesst sich "
"die Rueckleit-Adresse nicht bilden (oder %sREDIRECT_URI setzen).",
slug,
p,
)
continue
# Das /api-Praefix entsteht erst in nginx, nicht in FastAPI – deshalb
# gehoert es hier fest hinein und darf nicht aus dem Request kommen.
redirect = f"{basis}/api/auth/oidc/{slug}/callback"
def _scopes(schluessel: str, standard: str) -> str:
wert = (env.get(p + schluessel) or "").strip() or standard
# Ohne openid ist es kein OpenID Connect, sondern blosses OAuth –
# dann kaeme gar kein ID-Token zurueck.
return wert if "openid" in wert.split() else "openid " + wert
scopes = _scopes("SCOPES", STANDARD_SCOPES)
native_scopes = _scopes("NATIVE_SCOPES", STANDARD_NATIVE_SCOPES)
anbieter.append(
OidcProvider(
slug=slug,
display_name=(env.get(p + "DISPLAY_NAME") or "").strip() or slug.capitalize(),
discovery_url=discovery,
client_id=client_id,
client_secret=(env.get(p + "CLIENT_SECRET") or "").strip(),
scopes=scopes,
native_scopes=native_scopes,
native_client_ids=_liste(env.get(p + "NATIVE_CLIENT_IDS")),
native_redirect_uri=(env.get(p + "NATIVE_REDIRECT_URI") or "").strip()
or STANDARD_NATIVE_REDIRECT,
redirect_uri=redirect,
auto_create=_wahrheitswert(env.get(p + "AUTO_CREATE"), True),
require_verified_email=_wahrheitswert(
env.get(p + "REQUIRE_VERIFIED_EMAIL"), True
),
)
)
return anbieter
@lru_cache
def registrierte_provider() -> tuple[OidcProvider, ...]:
"""Die Anbieter dieses Prozesses (einmal beim ersten Zugriff gelesen)."""
from .config import get_settings
return tuple(provider_aus_umgebung(os.environ, get_settings().public_base_url))
def provider_finden(slug: str) -> OidcProvider | None:
for p in registrierte_provider():
if p.slug == slug:
return p
return None