From bd2a0e1d7d609997ba6a9d98cd695ee76d184fc5 Mon Sep 17 00:00:00 2001 From: "authentik-automation[bot]" <135050075+authentik-automation[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:25:14 +0200 Subject: [PATCH] providers/oauth2: clip device authorization scope against the provider's ScopeMapping set (cherry-pick #21701 to version-2026.2) (#21799) providers/oauth2: clip device authorization scope against the provider's ScopeMapping set (#21701) * providers/oauth2: clip device authorization scope against the provider's ScopeMapping set DeviceView.parse_request stored the raw request scope straight onto the DeviceToken: self.scopes = self.request.POST.get("scope", "").split(" ") ... token = DeviceToken.objects.create(..., _scope=" ".join(self.scopes)) The token-exchange side then reads those scopes back directly: if SCOPE_OFFLINE_ACCESS in self.params.device_code.scope: refresh_token = RefreshToken(...) ... so a caller that adds offline_access to the device authorization request body gets a refresh_token at the exchange, even when the provider has no offline_access ScopeMapping configured. Every other grant type clips scope against ScopeMapping for the provider inside TokenParams.__check_scopes, but the device authorization endpoint runs before TokenParams is ever constructed, so the clip never happens for the device flow. Combined with #20828 (missing client_secret verification on device code exchange for confidential clients, now being fixed separately) and the lack of per-app opt-out for the device flow, this gives any caller that knows the client_id a path to an offline refresh token against any OIDC application the deployment exposes. Intersect the requested scope set with the provider's ScopeMapping names before we ever persist the DeviceToken. offline_access that is not configured is silently dropped, matching __check_scopes on the other grant types. Configured offline_access still flows through unchanged. Fixes #20825 * rework and add tests --------- Signed-off-by: SAY-5 Signed-off-by: Jens Langhammer Co-authored-by: Sai Asish Y Co-authored-by: SAY-5 Co-authored-by: Jens Langhammer --- .../oauth2/tests/test_device_backchannel.py | 57 ++++++++++++++++++- .../oauth2/views/device_backchannel.py | 20 ++++++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/authentik/providers/oauth2/tests/test_device_backchannel.py b/authentik/providers/oauth2/tests/test_device_backchannel.py index 1e75b7f8ef..1c54ca54c4 100644 --- a/authentik/providers/oauth2/tests/test_device_backchannel.py +++ b/authentik/providers/oauth2/tests/test_device_backchannel.py @@ -6,10 +6,11 @@ from urllib.parse import quote from django.urls import reverse +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application from authentik.core.tests.utils import create_test_flow from authentik.lib.generators import generate_id -from authentik.providers.oauth2.models import OAuth2Provider +from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping from authentik.providers.oauth2.tests.utils import OAuthTestCase @@ -110,3 +111,57 @@ class TesOAuth2DeviceBackchannel(OAuthTestCase): self.assertEqual(res.status_code, 200) body = loads(res.content.decode()) self.assertEqual(body["expires_in"], 60) + + @apply_blueprint("system/providers-oauth2.yaml") + def test_backchannel_scopes(self): + """Test backchannel""" + self.provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + ] + ) + ) + creds = b64encode(f"{self.provider.client_id}:".encode()).decode() + res = self.client.post( + reverse("authentik_providers_oauth2:device"), + HTTP_AUTHORIZATION=f"Basic {creds}", + data={"scope": "openid email"}, + ) + self.assertEqual(res.status_code, 200) + body = loads(res.content.decode()) + self.assertEqual(body["expires_in"], 60) + token = DeviceToken.objects.filter(device_code=body["device_code"]).first() + self.assertIsNotNone(token) + self.assertEqual(len(token.scope), 2) + self.assertIn("openid", token.scope) + self.assertIn("email", token.scope) + + @apply_blueprint("system/providers-oauth2.yaml") + def test_backchannel_scopes_extra(self): + """Test backchannel""" + self.provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + ] + ) + ) + creds = b64encode(f"{self.provider.client_id}:".encode()).decode() + res = self.client.post( + reverse("authentik_providers_oauth2:device"), + HTTP_AUTHORIZATION=f"Basic {creds}", + data={"scope": "openid email foo"}, + ) + self.assertEqual(res.status_code, 200) + body = loads(res.content.decode()) + self.assertEqual(body["expires_in"], 60) + token = DeviceToken.objects.filter(device_code=body["device_code"]).first() + self.assertIsNotNone(token) + self.assertEqual(len(token.scope), 2) + self.assertIn("openid", token.scope) + self.assertIn("email", token.scope) diff --git a/authentik/providers/oauth2/views/device_backchannel.py b/authentik/providers/oauth2/views/device_backchannel.py index d8e73f9894..301f36492d 100644 --- a/authentik/providers/oauth2/views/device_backchannel.py +++ b/authentik/providers/oauth2/views/device_backchannel.py @@ -15,7 +15,7 @@ from authentik.core.models import Application from authentik.lib.config import CONFIG from authentik.lib.utils.time import timedelta_from_string from authentik.providers.oauth2.errors import DeviceCodeError -from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider +from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider, ScopeMapping from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth from authentik.providers.oauth2.views.device_init import QS_KEY_CODE @@ -28,7 +28,7 @@ class DeviceView(View): client_id: str provider: OAuth2Provider - scopes: list[str] = [] + scopes: set[str] = [] def parse_request(self): """Parse incoming request""" @@ -44,7 +44,21 @@ class DeviceView(View): raise DeviceCodeError("invalid_client") from None self.provider = provider self.client_id = client_id - self.scopes = self.request.POST.get("scope", "").split(" ") + + scopes_to_check = set(self.request.POST.get("scope", "").split()) + default_scope_names = set( + ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( + "scope_name", flat=True + ) + ) + self.scopes = scopes_to_check + if not scopes_to_check.issubset(default_scope_names): + LOGGER.info( + "Application requested scopes not configured, setting to overlap", + scope_allowed=default_scope_names, + scope_given=self.scopes, + ) + self.scopes = self.scopes.intersection(default_scope_names) def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: throttle = AnonRateThrottle()