feat(oidc): Mobile-Scopes serverseitig konfigurierbar (offline_access)
Die Apps duerfen keine Scopes erfinden: ein Scope, den der Provider nicht kennt, laesst ihn die gesamte Authorization-Anfrage ablehnen. Daher legt jetzt der Server fest, was die nativen Clients anfragen. OIDC_<KEY>_MOBILE_SCOPES ist frei setzbar; ohne Angabe sind es die Web-Scopes plus offline_access, damit Authentik ab 2024.2 ueberhaupt ein Refresh-Token ausstellt. Der Browser-Flow bleibt unveraendert, der braucht kein offline_access. Ausgeliefert wird der Wert als mobile_scopes in /api/auth/oidc/providers, aber nur wenn eine MOBILE_CLIENT_ID konfiguriert ist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
25fde49cee
commit
07235fd4c9
+15
-1
@@ -43,6 +43,7 @@ class OIDCProvider:
|
|||||||
scopes: str = DEFAULT_SCOPES
|
scopes: str = DEFAULT_SCOPES
|
||||||
redirect_uri: str = "" # empty => derived from public_base()
|
redirect_uri: str = "" # empty => derived from public_base()
|
||||||
mobile_client_ids: tuple = () # public native clients (AppAuth)
|
mobile_client_ids: tuple = () # public native clients (AppAuth)
|
||||||
|
mobile_scopes: str = "" # scopes the apps request (see below)
|
||||||
mobile_issuer: str = "" # only if mobile uses a 2nd application
|
mobile_issuer: str = "" # only if mobile uses a 2nd application
|
||||||
allow_signup: bool = False # just-in-time provisioning
|
allow_signup: bool = False # just-in-time provisioning
|
||||||
allowed_domains: tuple = () # email domain allowlist for signup
|
allowed_domains: tuple = () # email domain allowlist for signup
|
||||||
@@ -90,13 +91,26 @@ def _build_provider(key: str) -> Optional[OIDCProvider]:
|
|||||||
p.strip() for p in _env(key, "MOBILE_CLIENT_ID").split(",") if p.strip()
|
p.strip() for p in _env(key, "MOBILE_CLIENT_ID").split(",") if p.strip()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scopes = _env(key, "SCOPES") or DEFAULT_SCOPES
|
||||||
|
# The native apps need a refresh token, which requires `offline_access`.
|
||||||
|
# Since Authentik 2024.2 that scope must be mapped explicitly, and some
|
||||||
|
# other providers reject an unknown scope outright — so it is the server,
|
||||||
|
# not the app, that decides. Overridable per provider without a code change.
|
||||||
|
mobile_scopes = _env(key, "MOBILE_SCOPES")
|
||||||
|
if not mobile_scopes:
|
||||||
|
wanted = scopes.split()
|
||||||
|
if "offline_access" not in wanted:
|
||||||
|
wanted.append("offline_access")
|
||||||
|
mobile_scopes = " ".join(wanted)
|
||||||
|
|
||||||
return OIDCProvider(
|
return OIDCProvider(
|
||||||
key=key,
|
key=key,
|
||||||
name=_env(key, "NAME") or key.capitalize(),
|
name=_env(key, "NAME") or key.capitalize(),
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
client_secret=_env(key, "CLIENT_SECRET"),
|
client_secret=_env(key, "CLIENT_SECRET"),
|
||||||
scopes=_env(key, "SCOPES") or DEFAULT_SCOPES,
|
scopes=scopes,
|
||||||
|
mobile_scopes=mobile_scopes,
|
||||||
redirect_uri=_env(key, "REDIRECT_URI"),
|
redirect_uri=_env(key, "REDIRECT_URI"),
|
||||||
mobile_client_ids=mobile_ids,
|
mobile_client_ids=mobile_ids,
|
||||||
mobile_issuer=_env(key, "MOBILE_ISSUER"),
|
mobile_issuer=_env(key, "MOBILE_ISSUER"),
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ def list_providers():
|
|||||||
}
|
}
|
||||||
if provider.mobile_client_ids:
|
if provider.mobile_client_ids:
|
||||||
entry["mobile_client_id"] = provider.mobile_client_ids[0]
|
entry["mobile_client_id"] = provider.mobile_client_ids[0]
|
||||||
|
# The apps request exactly these — they must not invent scopes of
|
||||||
|
# their own, or a provider that lacks one rejects the whole flow.
|
||||||
|
entry["mobile_scopes"] = provider.mobile_scopes
|
||||||
try:
|
try:
|
||||||
doc = oidc_client.discover(provider)
|
doc = oidc_client.discover(provider)
|
||||||
entry["authorization_endpoint"] = doc.get("authorization_endpoint")
|
entry["authorization_endpoint"] = doc.get("authorization_endpoint")
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ def test_providers_lists_endpoints(client, authentik):
|
|||||||
assert p["authorization_endpoint"] == _discovery()["authorization_endpoint"]
|
assert p["authorization_endpoint"] == _discovery()["authorization_endpoint"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_scopes_include_offline_access_by_default(client, authentik):
|
||||||
|
"""The apps need a refresh token, so the server asks for offline_access."""
|
||||||
|
p = client.get("/api/auth/oidc/providers").json()["providers"][0]
|
||||||
|
assert "offline_access" in p["mobile_scopes"].split()
|
||||||
|
# The browser flow does not need it and must not be changed by this.
|
||||||
|
assert "offline_access" not in p["scopes"].split()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_scopes_are_overridable(client, authentik, monkeypatch):
|
||||||
|
"""A provider that cannot grant offline_access must be configurable."""
|
||||||
|
monkeypatch.setenv("OIDC_AUTHENTIK_MOBILE_SCOPES", "openid email")
|
||||||
|
oidc_config.reset_cache()
|
||||||
|
p = client.get("/api/auth/oidc/providers").json()["providers"][0]
|
||||||
|
assert p["mobile_scopes"] == "openid email"
|
||||||
|
|
||||||
|
|
||||||
def test_providers_never_leaks_client_secret(client, authentik, monkeypatch):
|
def test_providers_never_leaks_client_secret(client, authentik, monkeypatch):
|
||||||
secret = "super-secret-value-42"
|
secret = "super-secret-value-42"
|
||||||
monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_SECRET", secret)
|
monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_SECRET", secret)
|
||||||
|
|||||||
Reference in New Issue
Block a user