From 31b3624cc70fe292718ce2446a4d9900afb932b8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 9 Sep 2026 22:00:37 +0200 Subject: [PATCH] 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__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 --- README.md | 27 ++++++++++++++++-------- backend/app/oidc_config.py | 13 ++++++++++++ backend/app/routers/oidc.py | 6 +++++- backend/app/services/oidc.py | 40 +++++++++++++++++++++--------------- backend/tests/test_oidc.py | 21 +++++++++++++++++++ deploy/.env.example | 5 +++++ deploy/install.sh | 5 +++++ 7 files changed, 91 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 91d7210..fdaf2b1 100644 --- a/README.md +++ b/README.md @@ -105,20 +105,28 @@ Neben dem Passwort-Login kann sich Vorrania an einen OpenID-Connect-Dienst hängen (getestet mit Authentik). Ohne Konfiguration ändert sich nichts: Es gibt keinen SSO-Knopf, und der Passwort-Login bleibt der einzige Weg. -**1. In Authentik zwei Provider anlegen** – die Web-Oberfläche und die App -brauchen unterschiedliche Client-Typen: +**1. In Authentik zwei Anwendungen anlegen** – Web-UI und App brauchen +unterschiedliche Client-Typen, und ein Provider kann nur einer davon sein: -| Für | Typ | Redirect-URI | -|-----|-----|--------------| +| Für | Client-Typ | Redirect-URI | +|-----|-----------|--------------| | Web-UI | *Confidential* (mit Secret) | `https:///api/auth/oidc/authentik/callback` | | iOS-App | *Public* (ohne Secret, PKCE) | `vorrania://oauth/callback` | -Das `/api` in der Web-Adresse ist kein Versehen: Es entsteht im mitgelieferten -nginx und gehört deshalb in die bei Authentik hinterlegte Adresse. +Drei Punkte, an denen es sonst klemmt: -Damit die App ihre Anmeldung im Hintergrund erneuern kann, muss der -öffentliche Client `offline_access` ausliefern dürfen (in Authentik seit 2024.2 -ein eigenes Scope-Mapping) – sonst kommt kein Refresh-Token zurück. +- Das `/api` in der Web-Adresse ist kein Versehen: Es entsteht im mitgelieferten + nginx und gehört deshalb in die bei Authentik hinterlegte Adresse. +- **Signing Key setzen** (Provider ▸ *Advanced protocol settings*). Ohne ihn + signiert Authentik mit HS256, und Vorrania prüft gegen den öffentlichen + Schlüsselsatz – das Token würde abgelehnt. +- Jede Anwendung hat in Authentik **ihren eigenen Issuer** (aus dem + Application-Slug). Deshalb bekommt der App-Client eine eigene + Discovery-Adresse: `OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL`. + +Damit die App ihre Anmeldung im Hintergrund erneuern kann, muss der öffentliche +Client `offline_access` ausliefern dürfen (in Authentik seit 2024.2 ein eigenes +Scope-Mapping) – sonst kommt kein Refresh-Token zurück. **2. `deploy/.env` ergänzen** (vollständige Vorlage in `deploy/.env.example`): @@ -130,6 +138,7 @@ OIDC_AUTHENTIK_DISCOVERY_URL=https://authentik.example.com/application/o/vorrani OIDC_AUTHENTIK_CLIENT_ID=... OIDC_AUTHENTIK_CLIENT_SECRET=... OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios +OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania-ios/.well-known/openid-configuration ``` Danach `./update.sh` (oder `docker compose --env-file .env up -d`). diff --git a/backend/app/oidc_config.py b/backend/app/oidc_config.py index 12b6cc6..acd8611 100644 --- a/backend/app/oidc_config.py +++ b/backend/app/oidc_config.py @@ -55,6 +55,12 @@ class OidcProvider: 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 = "" @@ -71,6 +77,11 @@ class OidcProvider: # 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.""" @@ -131,6 +142,7 @@ def provider_aus_umgebung( ) 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: @@ -159,6 +171,7 @@ def provider_aus_umgebung( 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, diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index 9d7a667..6c5e9b9 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -76,7 +76,8 @@ def anbieter_auflisten() -> list[OidcProviderOut]: OidcProviderOut( slug=p.slug, display_name=p.display_name, - discovery_url=p.discovery_url, + # Die App prueft gegen ihre eigene Anwendung beim Anbieter. + discovery_url=p.app_discovery_url, native_client_id=p.native_client_ids[0] if p.native_client_ids else None, native_redirect_uri=p.native_redirect_uri, scopes=p.native_scopes, @@ -227,6 +228,9 @@ def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get # eingereicht wird. nonce=payload.nonce, erlaubte_audiences=provider.native_client_ids, + # Eigener Issuer und Schluesselsatz, falls der App-Client beim + # Anbieter eine getrennte Anwendung ist (bei Authentik der Normalfall). + discovery_url=provider.app_discovery_url, ) nutzer = dienst.benutzer_aus_claims(db, provider, claims) except dienst.OidcFehler as fehler: diff --git a/backend/app/services/oidc.py b/backend/app/services/oidc.py index b4dcbbb..96791dc 100644 --- a/backend/app/services/oidc.py +++ b/backend/app/services/oidc.py @@ -76,30 +76,35 @@ def _holen(url: str) -> dict[str, Any]: ) from fehler -def metadaten(provider: OidcProvider, erzwingen: bool = False) -> dict[str, Any]: - """Discovery-Dokument des Anbieters (gepuffert).""" - eintrag = _metadaten_cache.get(provider.slug) +def metadaten(discovery_url: str, erzwingen: bool = False) -> dict[str, Any]: + """Discovery-Dokument (gepuffert). + + Gepuffert wird nach der Adresse, nicht nach dem Anbieter: Browser- und + App-Client koennen bei Authentik zwei getrennte Anwendungen mit je eigenem + Issuer und eigenem Schluesselsatz sein. + """ + eintrag = _metadaten_cache.get(discovery_url) if not erzwingen and eintrag and eintrag[0] > time.monotonic(): return eintrag[1] - daten = _holen(provider.discovery_url) + daten = _holen(discovery_url) for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"): if not daten.get(pflicht): raise OidcFehler( "discovery_unvollstaendig", - f"Discovery-Dokument von '{provider.slug}' ohne '{pflicht}'.", + f"Discovery-Dokument unter {discovery_url} ohne '{pflicht}'.", ) - _metadaten_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, daten) + _metadaten_cache[discovery_url] = (time.monotonic() + CACHE_SEKUNDEN, daten) return daten -def _jwks(provider: OidcProvider, erzwingen: bool = False) -> Any: - eintrag = _jwks_cache.get(provider.slug) +def _jwks(discovery_url: str, erzwingen: bool = False) -> Any: + eintrag = _jwks_cache.get(discovery_url) if not erzwingen and eintrag and eintrag[0] > time.monotonic(): return eintrag[1] - schluessel = JsonWebKey.import_key_set(_holen(metadaten(provider)["jwks_uri"])) - _jwks_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, schluessel) + schluessel = JsonWebKey.import_key_set(_holen(metadaten(discovery_url)["jwks_uri"])) + _jwks_cache[discovery_url] = (time.monotonic() + CACHE_SEKUNDEN, schluessel) return schluessel @@ -118,7 +123,7 @@ def _client(provider: OidcProvider, redirect_uri: str) -> OAuth2Client: def anmelde_url(provider: OidcProvider, state: str, nonce: str, code_verifier: str) -> str: """Adresse, zu der der Browser fuer die Anmeldung geschickt wird.""" - meta = metadaten(provider) + meta = metadaten(provider.discovery_url) with _client(provider, provider.redirect_uri) as client: url, _ = client.create_authorization_url( meta["authorization_endpoint"], @@ -131,7 +136,7 @@ def anmelde_url(provider: OidcProvider, state: str, nonce: str, code_verifier: s def code_einloesen(provider: OidcProvider, code: str, code_verifier: str) -> dict[str, Any]: """Tauscht den Autorisierungs-Code gegen Tokens (vertraulicher Web-Client).""" - meta = metadaten(provider) + meta = metadaten(provider.discovery_url) try: with _client(provider, provider.redirect_uri) as client: return client.fetch_token( @@ -154,14 +159,17 @@ def pruefe_id_token( id_token: str, nonce: str | None = None, erlaubte_audiences: tuple[str, ...] | None = None, + discovery_url: str | None = None, ) -> dict[str, Any]: """Prueft Signatur, ``iss``, ``aud``, ``exp`` (und ``nonce``, falls bekannt). ``erlaubte_audiences`` schraenkt die zulaessigen Empfaenger ein: der Web-Rueckweg akzeptiert nur die vertrauliche Client-ID, der App-Weg nur die - der registrierten oeffentlichen Clients. + der registrierten oeffentlichen Clients. ``discovery_url`` bestimmt, gegen + welchen Issuer und welchen Schluesselsatz geprueft wird. """ - meta = metadaten(provider) + quelle = discovery_url or provider.discovery_url + meta = metadaten(quelle) audiences = erlaubte_audiences or provider.erlaubte_audiences if not audiences: raise OidcFehler( @@ -187,10 +195,10 @@ def pruefe_id_token( try: try: - claims = _entschluesseln(_jwks(provider)) + claims = _entschluesseln(_jwks(quelle)) except JoseError: # Kann ein frisch gedrehter Signaturschluessel sein – einmal neu laden. - claims = _entschluesseln(_jwks(provider, erzwingen=True)) + claims = _entschluesseln(_jwks(quelle, erzwingen=True)) claims.validate(leeway=UHREN_SPIELRAUM) except JoseError as fehler: raise OidcFehler("id_token_ungueltig", f"ID-Token abgelehnt: {fehler}") from fehler diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py index d19d089..d7defe9 100644 --- a/backend/tests/test_oidc.py +++ b/backend/tests/test_oidc.py @@ -410,3 +410,24 @@ def test_zettel_merkt_sich_das_zu_verknuepfende_konto(db): assert eingeloest.link_user_id == eva.id # Eine normale Anmeldung traegt das Feld nicht. assert oidc_state.einloesen(db, oidc_state.anlegen(db, "authentik").state).link_user_id is None + + +def test_app_client_darf_eigene_discovery_adresse_haben(): + """In Authentik hat jede Anwendung standardmaessig einen eigenen Issuer. + + Web- und App-Client brauchen dort zwei getrennte Anwendungen – ohne eigene + Discovery-Adresse wuerde die iss-Pruefung des App-Tokens fehlschlagen. + """ + umgebung = dict(VOLLE_UMGEBUNG) + umgebung["OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL"] = ( + "https://idp.example.com/application/o/vorrania-ios/.well-known/openid-configuration" + ) + (p,) = provider_aus_umgebung(umgebung, BASIS) + + assert p.app_discovery_url == umgebung["OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL"] + assert p.discovery_url != p.app_discovery_url + + +def test_ohne_eigene_adresse_teilen_sich_beide_wege_die_discovery(): + (p,) = provider_aus_umgebung(VOLLE_UMGEBUNG, BASIS) + assert p.app_discovery_url == p.discovery_url diff --git a/deploy/.env.example b/deploy/.env.example index 4011736..40e7161 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -39,6 +39,11 @@ OIDC_PROVIDERS= #OIDC_AUTHENTIK_CLIENT_SECRET= # Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE: #OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios +# In Authentik hat JEDE Anwendung ihren eigenen Issuer. Web- und App-Client +# brauchen dort zwei getrennte Anwendungen - deshalb hier die Discovery-Adresse +# der App-Anwendung eintragen. Nur weglassen, wenn beide Clients unter derselben +# Anwendung laufen (bzw. der Issuer-Modus auf "global" steht). +#OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania-ios/.well-known/openid-configuration # Neue Konten automatisch anlegen (Rolle "Benutzer"); false = nur bereits # vorhandene Konten duerfen sich per SSO anmelden. #OIDC_AUTHENTIK_AUTO_CREATE=true diff --git a/deploy/install.sh b/deploy/install.sh index 02a6a06..2ccf793 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -154,6 +154,11 @@ OIDC_PROVIDERS= #OIDC_AUTHENTIK_CLIENT_SECRET= # Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE: #OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios +# In Authentik hat JEDE Anwendung ihren eigenen Issuer. Web- und App-Client +# brauchen dort zwei getrennte Anwendungen - deshalb hier die Discovery-Adresse +# der App-Anwendung eintragen. Nur weglassen, wenn beide Clients unter derselben +# Anwendung laufen (bzw. der Issuer-Modus auf "global" steht). +#OIDC_AUTHENTIK_NATIVE_DISCOVERY_URL=https://authentik.example.com/application/o/vorrania-ios/.well-known/openid-configuration # Neue Konten automatisch anlegen (Rolle "Benutzer"); false = nur bereits # vorhandene Konten duerfen sich per SSO anmelden. #OIDC_AUTHENTIK_AUTO_CREATE=true