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 <SAY-5@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Sai Asish Y <say.apm35@gmail.com>
Co-authored-by: SAY-5 <SAY-5@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
authentik-automation[bot]
2026-04-23 15:25:14 +02:00
committed by GitHub
parent c4d455dd3a
commit bd2a0e1d7d
2 changed files with 73 additions and 4 deletions
@@ -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)
@@ -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()