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>
This commit is contained in:
Scarriffle
2026-09-09 22:00:37 +02:00
co-authored by Claude Opus 5
parent 3d0ff54bf5
commit 31b3624cc7
7 changed files with 91 additions and 26 deletions
+18 -9
View File
@@ -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 hängen (getestet mit Authentik). Ohne Konfiguration ändert sich nichts: Es gibt
keinen SSO-Knopf, und der Passwort-Login bleibt der einzige Weg. keinen SSO-Knopf, und der Passwort-Login bleibt der einzige Weg.
**1. In Authentik zwei Provider anlegen** – die Web-Oberfläche und die App **1. In Authentik zwei Anwendungen anlegen** – Web-UI und App brauchen
brauchen unterschiedliche Client-Typen: 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://<deine-adresse>/api/auth/oidc/authentik/callback` | | Web-UI | *Confidential* (mit Secret) | `https://<deine-adresse>/api/auth/oidc/authentik/callback` |
| iOS-App | *Public* (ohne Secret, PKCE) | `vorrania://oauth/callback` | | iOS-App | *Public* (ohne Secret, PKCE) | `vorrania://oauth/callback` |
Das `/api` in der Web-Adresse ist kein Versehen: Es entsteht im mitgelieferten Drei Punkte, an denen es sonst klemmt:
nginx und gehört deshalb in die bei Authentik hinterlegte Adresse.
Damit die App ihre Anmeldung im Hintergrund erneuern kann, muss der - Das `/api` in der Web-Adresse ist kein Versehen: Es entsteht im mitgelieferten
öffentliche Client `offline_access` ausliefern dürfen (in Authentik seit 2024.2 nginx und gehört deshalb in die bei Authentik hinterlegte Adresse.
ein eigenes Scope-Mapping) – sonst kommt kein Refresh-Token zurück. - **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`): **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_ID=...
OIDC_AUTHENTIK_CLIENT_SECRET=... OIDC_AUTHENTIK_CLIENT_SECRET=...
OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios 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`). Danach `./update.sh` (oder `docker compose --env-file .env up -d`).
+13
View File
@@ -55,6 +55,12 @@ class OidcProvider:
display_name: str display_name: str
discovery_url: str discovery_url: str
client_id: 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 # 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 und sendet nie eins – deshalb steht es hier auch nur fuer den Web-Weg.
client_secret: str = "" client_secret: str = ""
@@ -71,6 +77,11 @@ class OidcProvider:
# eines fremden Vorrania-Kontos. # eines fremden Vorrania-Kontos.
require_verified_email: bool = True 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 @property
def erlaubte_audiences(self) -> tuple[str, ...]: def erlaubte_audiences(self) -> tuple[str, ...]:
"""Alle aud-Werte, die ein ID-Token dieses Anbieters tragen darf.""" """Alle aud-Werte, die ein ID-Token dieses Anbieters tragen darf."""
@@ -131,6 +142,7 @@ def provider_aus_umgebung(
) )
continue continue
native_discovery = (env.get(p + "NATIVE_DISCOVERY_URL") or "").strip()
redirect = (env.get(p + "REDIRECT_URI") or "").strip() redirect = (env.get(p + "REDIRECT_URI") or "").strip()
if not redirect: if not redirect:
if not basis: if not basis:
@@ -159,6 +171,7 @@ def provider_aus_umgebung(
slug=slug, slug=slug,
display_name=(env.get(p + "DISPLAY_NAME") or "").strip() or slug.capitalize(), display_name=(env.get(p + "DISPLAY_NAME") or "").strip() or slug.capitalize(),
discovery_url=discovery, discovery_url=discovery,
native_discovery_url=native_discovery,
client_id=client_id, client_id=client_id,
client_secret=(env.get(p + "CLIENT_SECRET") or "").strip(), client_secret=(env.get(p + "CLIENT_SECRET") or "").strip(),
scopes=scopes, scopes=scopes,
+5 -1
View File
@@ -76,7 +76,8 @@ def anbieter_auflisten() -> list[OidcProviderOut]:
OidcProviderOut( OidcProviderOut(
slug=p.slug, slug=p.slug,
display_name=p.display_name, 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_client_id=p.native_client_ids[0] if p.native_client_ids else None,
native_redirect_uri=p.native_redirect_uri, native_redirect_uri=p.native_redirect_uri,
scopes=p.native_scopes, scopes=p.native_scopes,
@@ -227,6 +228,9 @@ def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get
# eingereicht wird. # eingereicht wird.
nonce=payload.nonce, nonce=payload.nonce,
erlaubte_audiences=provider.native_client_ids, 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) nutzer = dienst.benutzer_aus_claims(db, provider, claims)
except dienst.OidcFehler as fehler: except dienst.OidcFehler as fehler:
+24 -16
View File
@@ -76,30 +76,35 @@ def _holen(url: str) -> dict[str, Any]:
) from fehler ) from fehler
def metadaten(provider: OidcProvider, erzwingen: bool = False) -> dict[str, Any]: def metadaten(discovery_url: str, erzwingen: bool = False) -> dict[str, Any]:
"""Discovery-Dokument des Anbieters (gepuffert).""" """Discovery-Dokument (gepuffert).
eintrag = _metadaten_cache.get(provider.slug)
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(): if not erzwingen and eintrag and eintrag[0] > time.monotonic():
return eintrag[1] return eintrag[1]
daten = _holen(provider.discovery_url) daten = _holen(discovery_url)
for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"): for pflicht in ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"):
if not daten.get(pflicht): if not daten.get(pflicht):
raise OidcFehler( raise OidcFehler(
"discovery_unvollstaendig", "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 return daten
def _jwks(provider: OidcProvider, erzwingen: bool = False) -> Any: def _jwks(discovery_url: str, erzwingen: bool = False) -> Any:
eintrag = _jwks_cache.get(provider.slug) eintrag = _jwks_cache.get(discovery_url)
if not erzwingen and eintrag and eintrag[0] > time.monotonic(): if not erzwingen and eintrag and eintrag[0] > time.monotonic():
return eintrag[1] return eintrag[1]
schluessel = JsonWebKey.import_key_set(_holen(metadaten(provider)["jwks_uri"])) schluessel = JsonWebKey.import_key_set(_holen(metadaten(discovery_url)["jwks_uri"]))
_jwks_cache[provider.slug] = (time.monotonic() + CACHE_SEKUNDEN, schluessel) _jwks_cache[discovery_url] = (time.monotonic() + CACHE_SEKUNDEN, schluessel)
return 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: def anmelde_url(provider: OidcProvider, state: str, nonce: str, code_verifier: str) -> str:
"""Adresse, zu der der Browser fuer die Anmeldung geschickt wird.""" """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: with _client(provider, provider.redirect_uri) as client:
url, _ = client.create_authorization_url( url, _ = client.create_authorization_url(
meta["authorization_endpoint"], 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]: def code_einloesen(provider: OidcProvider, code: str, code_verifier: str) -> dict[str, Any]:
"""Tauscht den Autorisierungs-Code gegen Tokens (vertraulicher Web-Client).""" """Tauscht den Autorisierungs-Code gegen Tokens (vertraulicher Web-Client)."""
meta = metadaten(provider) meta = metadaten(provider.discovery_url)
try: try:
with _client(provider, provider.redirect_uri) as client: with _client(provider, provider.redirect_uri) as client:
return client.fetch_token( return client.fetch_token(
@@ -154,14 +159,17 @@ def pruefe_id_token(
id_token: str, id_token: str,
nonce: str | None = None, nonce: str | None = None,
erlaubte_audiences: tuple[str, ...] | None = None, erlaubte_audiences: tuple[str, ...] | None = None,
discovery_url: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Prueft Signatur, ``iss``, ``aud``, ``exp`` (und ``nonce``, falls bekannt). """Prueft Signatur, ``iss``, ``aud``, ``exp`` (und ``nonce``, falls bekannt).
``erlaubte_audiences`` schraenkt die zulaessigen Empfaenger ein: der ``erlaubte_audiences`` schraenkt die zulaessigen Empfaenger ein: der
Web-Rueckweg akzeptiert nur die vertrauliche Client-ID, der App-Weg nur die 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 audiences = erlaubte_audiences or provider.erlaubte_audiences
if not audiences: if not audiences:
raise OidcFehler( raise OidcFehler(
@@ -187,10 +195,10 @@ def pruefe_id_token(
try: try:
try: try:
claims = _entschluesseln(_jwks(provider)) claims = _entschluesseln(_jwks(quelle))
except JoseError: except JoseError:
# Kann ein frisch gedrehter Signaturschluessel sein – einmal neu laden. # 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) claims.validate(leeway=UHREN_SPIELRAUM)
except JoseError as fehler: except JoseError as fehler:
raise OidcFehler("id_token_ungueltig", f"ID-Token abgelehnt: {fehler}") from fehler raise OidcFehler("id_token_ungueltig", f"ID-Token abgelehnt: {fehler}") from fehler
+21
View File
@@ -410,3 +410,24 @@ def test_zettel_merkt_sich_das_zu_verknuepfende_konto(db):
assert eingeloest.link_user_id == eva.id assert eingeloest.link_user_id == eva.id
# Eine normale Anmeldung traegt das Feld nicht. # Eine normale Anmeldung traegt das Feld nicht.
assert oidc_state.einloesen(db, oidc_state.anlegen(db, "authentik").state).link_user_id is None 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
+5
View File
@@ -39,6 +39,11 @@ OIDC_PROVIDERS=
#OIDC_AUTHENTIK_CLIENT_SECRET= #OIDC_AUTHENTIK_CLIENT_SECRET=
# Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE: # Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE:
#OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios #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 # Neue Konten automatisch anlegen (Rolle "Benutzer"); false = nur bereits
# vorhandene Konten duerfen sich per SSO anmelden. # vorhandene Konten duerfen sich per SSO anmelden.
#OIDC_AUTHENTIK_AUTO_CREATE=true #OIDC_AUTHENTIK_AUTO_CREATE=true
+5
View File
@@ -154,6 +154,11 @@ OIDC_PROVIDERS=
#OIDC_AUTHENTIK_CLIENT_SECRET= #OIDC_AUTHENTIK_CLIENT_SECRET=
# Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE: # Oeffentlicher Client (iOS-App) - ohne Secret, mit PKCE:
#OIDC_AUTHENTIK_NATIVE_CLIENT_IDS=vorrania-ios #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 # Neue Konten automatisch anlegen (Rolle "Benutzer"); false = nur bereits
# vorhandene Konten duerfen sich per SSO anmelden. # vorhandene Konten duerfen sich per SSO anmelden.
#OIDC_AUTHENTIK_AUTO_CREATE=true #OIDC_AUTHENTIK_AUTO_CREATE=true