"""Welche OpenID-Connect-Anbieter eingerichtet sind. Bewusst aus Umgebungsvariablen und nicht aus der Datenbank: Client-ID und -Secret gehoeren zur Betriebsumgebung, nicht in ein Backup, und wer die Anwendung umzieht, soll sie neu setzen muessen. Ein zweiter Anbieter ist reine Konfiguration – kein Code aendert sich: APP_OIDC_PROVIDERS=authentik,google APP_OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example/application/o/certain/.well-known/openid-configuration APP_OIDC_AUTHENTIK_CLIENT_ID=... APP_OIDC_AUTHENTIK_CLIENT_SECRET=... APP_OIDC_AUTHENTIK_LABEL=Authentik Der Schluessel in ``APP_OIDC_PROVIDERS`` ist zugleich der Name in der Adresse (``/api/auth/oidc/authentik/start``) und in der Datenbank – er darf sich spaeter nicht aendern, sonst finden bestehende Verknuepfungen ihren Anbieter nicht mehr wieder. """ from __future__ import annotations import os import re from dataclasses import dataclass, field from functools import lru_cache # Nur was sich gefahrlos in eine URL und eine Datenbankspalte schreiben laesst. NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$") # Ohne diese drei Scopes fehlt entweder das ID-Token oder die E-Mail, ueber die # ein bestehendes Konto gefunden wird. STANDARD_SCOPES = "openid profile email" @dataclass(frozen=True) class Provider: name: str # "authentik" – Schluessel in URL und Datenbank label: str # "Mit Authentik anmelden" – was auf dem Knopf steht discovery_url: str client_id: str client_secret: str scopes: str = STANDARD_SCOPES # Darf jemand, den es in Certain noch nicht gibt, ein Konto bekommen? # # Vorgabe NEIN, und das ist Absicht: Wer schon ein Konto hat und sich zum # ersten Mal ueber den Anbieter anmeldet, bekaeme sonst ein zweites, leeres # – seine Daten laegen weiter unter dem alten. Abgewiesen zu werden mit dem # Hinweis "erst anmelden, dann verknuepfen" ist die hilfreichere Antwort. # # Auf true stellen, wenn sich neue Leute selbst anmelden koennen sollen. # Dann entscheidet der Anbieter, wer in Certain darf. auto_create: bool = False # Wenn gesetzt: nur E-Mails dieser Domaenen duerfen neu anlegen. allowed_domains: tuple[str, ...] = field(default_factory=tuple) def _env(name: str, provider: str, standard: str = "") -> str: return os.environ.get(f"APP_OIDC_{provider.upper()}_{name}", standard).strip() def _bool(wert: str, standard: bool) -> bool: if not wert: return standard return wert.lower() in ("1", "true", "yes", "ja", "on") @lru_cache def providers() -> dict[str, Provider]: """Die eingerichteten Anbieter, nach Namen. Unvollstaendig konfigurierte werden ausgelassen und NICHT halb angeboten: Ein Knopf, der ins Leere fuehrt, ist schlimmer als keiner. """ roh = os.environ.get("APP_OIDC_PROVIDERS", "") gefunden: dict[str, Provider] = {} for name in (n.strip().lower() for n in roh.split(",")): if not name or not NAME_MUSTER.match(name): continue discovery = _env("DISCOVERY_URL", name) client_id = _env("CLIENT_ID", name) if not discovery or not client_id: continue domains = tuple( d.strip().lower().lstrip("@") for d in _env("ALLOWED_DOMAINS", name).split(",") if d.strip() ) gefunden[name] = Provider( name=name, label=_env("LABEL", name) or name.capitalize(), discovery_url=discovery, client_id=client_id, client_secret=_env("CLIENT_SECRET", name), scopes=_env("SCOPES", name) or STANDARD_SCOPES, auto_create=_bool(_env("AUTO_CREATE", name), False), allowed_domains=domains, ) return gefunden def get(name: str) -> Provider | None: return providers().get((name or "").lower()) def zuruecksetzen() -> None: """Den Zwischenspeicher leeren – fuer Tests, die die Umgebung aendern.""" providers.cache_clear()