In Authentik hat jede Anwendung standardmaessig ihren eigenen Issuer, abgeleitet aus dem Application-Slug. Web- und App-Client brauchen dort aber zwei getrennte Anwendungen, weil ein Provider entweder vertraulich ODER oeffentlich ist. Bisher wurde das App-Token gegen den Issuer und den Schluesselsatz der Web-Anwendung geprueft - die App-Anmeldung waere damit an der iss-Pruefung gescheitert. Discovery und JWKS werden jetzt je Adresse gepuffert statt je Anbieter, und OIDC_<KUERZEL>_NATIVE_DISCOVERY_URL benennt die Anwendung des App-Clients. Ohne die Variable bleibt es bei einer gemeinsamen Adresse. Ausserdem im README: der Signing Key muss gesetzt sein, sonst signiert Authentik mit HS256 und das Token wird abgelehnt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.1 KiB
Python
206 lines
8.1 KiB
Python
"""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
|
||
# 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
|