"""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/). 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 # Eigene Discovery-Adresse fuer den App-Client. In Authentik hat jede # Anwendung standardmaessig einen EIGENEN Issuer (aus ihrem Slug) – und # Web- und App-Client brauchen dort zwei getrennte Anwendungen. Ohne diese # Angabe schluege die iss-Pruefung des App-Tokens fehl. Leer = dieselbe # Adresse wie fuer den Browser-Weg. native_discovery_url: 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 app_discovery_url(self) -> str: """Discovery-Adresse, gegen die ein Token aus der App geprueft wird.""" return self.native_discovery_url or self.discovery_url @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 native_discovery = (env.get(p + "NATIVE_DISCOVERY_URL") or "").strip() 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, native_discovery_url=native_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