Files
Vorrania/backend/app/oidc_config.py
T
ScarriffleandClaude Opus 5 31b3624cc7 App-Client darf eine eigene Anwendung beim Anmeldedienst sein
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>
2026-09-09 22:00:37 +02:00

206 lines
8.1 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
# 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