diff --git a/backend/oidc_config.py b/backend/oidc_config.py index 4adf251..4480239 100644 --- a/backend/oidc_config.py +++ b/backend/oidc_config.py @@ -43,6 +43,7 @@ class OIDCProvider: scopes: str = DEFAULT_SCOPES redirect_uri: str = "" # empty => derived from public_base() 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 allow_signup: bool = False # just-in-time provisioning 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() ) + 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( key=key, name=_env(key, "NAME") or key.capitalize(), issuer=issuer, client_id=client_id, 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"), mobile_client_ids=mobile_ids, mobile_issuer=_env(key, "MOBILE_ISSUER"), diff --git a/backend/routers/oidc_router.py b/backend/routers/oidc_router.py index 3632f64..722d2c7 100644 --- a/backend/routers/oidc_router.py +++ b/backend/routers/oidc_router.py @@ -123,6 +123,9 @@ def list_providers(): } if provider.mobile_client_ids: 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: doc = oidc_client.discover(provider) entry["authorization_endpoint"] = doc.get("authorization_endpoint") diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py index 1c032d3..9e2611f 100644 --- a/backend/tests/test_oidc.py +++ b/backend/tests/test_oidc.py @@ -140,6 +140,22 @@ def test_providers_lists_endpoints(client, authentik): 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): secret = "super-secret-value-42" monkeypatch.setenv("OIDC_AUTHENTIK_CLIENT_SECRET", secret)