From b26e5bd7fd1a46adf8e414e16ec89aa68f38eb99 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:37:26 +0000 Subject: [PATCH 01/28] Add Device model This model represents the device session for the request and response stage See section 3.1(https://datatracker.ietf.org/doc/html/rfc8628#section-3.1) and 3.2(https://datatracker.ietf.org/doc/html/rfc8628#section-3.2) --- oauth2_provider/models.py | 105 +++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a76db37c0..b5d111ac7 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -3,7 +3,10 @@ import time import uuid from contextlib import suppress -from datetime import timedelta +from dataclasses import dataclass +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone +from typing import Optional from urllib.parse import parse_qsl, urlparse from django.apps import apps @@ -86,12 +89,14 @@ class AbstractApplication(models.Model): ) GRANT_AUTHORIZATION_CODE = "authorization-code" + GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), + (GRANT_DEVICE_CODE, _("Device Code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), @@ -650,11 +655,109 @@ class Meta(AbstractIDToken.Meta): swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" +class AbstractDevice(models.Model): + class Meta: + abstract = True + constraints = [ + models.UniqueConstraint( + fields=["device_code"], + name="%(app_label)s_%(class)s_unique_device_code", + ), + ] + + AUTHORIZED = "authorized" + AUTHORIZATION_PENDING = "authorization-pending" + EXPIRED = "expired" + DENIED = "denied" + + DEVICE_FLOW_STATUS = ( + (AUTHORIZED, _("Authorized")), + (AUTHORIZATION_PENDING, _("Authorization pending")), + (EXPIRED, _("Expired")), + (DENIED, _("Denied")), + ) + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="%(app_label)s_%(class)s", + null=True, + blank=True, + on_delete=models.CASCADE, + ) + device_code = models.CharField(max_length=100, unique=True) + user_code = models.CharField(max_length=100) + scope = models.CharField(max_length=64, null=True) + interval = models.IntegerField(default=5) + expires = models.DateTimeField() + status = models.CharField( + max_length=64, blank=True, choices=DEVICE_FLOW_STATUS, default=AUTHORIZATION_PENDING + ) + client_id = models.CharField(max_length=100, db_index=True) + last_checked = models.DateTimeField(auto_now=True) + + def is_expired(self): + """ + Check device flow session expiration. + """ + now = datetime.now(tz=dt_timezone.utc) + return now >= self.expires + + +class DeviceManager(models.Manager): + def get_by_natural_key(self, client_id, device_code, user_code): + return self.get(client_id=client_id, device_code=device_code, user_code=user_code) + + +class Device(AbstractDevice): + objects = DeviceManager() + + class Meta(AbstractDevice.Meta): + swappable = "OAUTH2_PROVIDER_DEVICE_MODEL" + + def natural_key(self): + return (self.client_id, self.device_code, self.user_code) + + +@dataclass +class DeviceRequest: + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 + # scope is optional + client_id: str + scope: Optional[str] = None + + +@dataclass +class DeviceCodeResponse: + verification_uri: str + expires_in: int + user_code: int + device_code: str + interval: int + + +def create_device(device_request: DeviceRequest, device_response: DeviceCodeResponse) -> Device: + now = datetime.now(tz=dt_timezone.utc) + + return Device.objects.create( + client_id=device_request.client_id, + device_code=device_response.device_code, + user_code=device_response.user_code, + scope=device_request.scope, + expires=now + timedelta(seconds=device_response.expires_in), + ) + + def get_application_model(): """Return the Application model that is active in this project.""" return apps.get_model(oauth2_settings.APPLICATION_MODEL) +def get_device_model(): + """Return the Device model that is active in this project.""" + return apps.get_model(oauth2_settings.DEVICE_MODEL) + + def get_grant_model(): """Return the Grant model that is active in this project.""" return apps.get_model(oauth2_settings.GRANT_MODEL) From 58a06b494affe655d3a6948a232542d3ba262f88 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:56:11 +0000 Subject: [PATCH 02/28] Adhere content-type request header to CGI standard Django represents headers according to the common gateway interface(CGI) standard. This means it's in all caps with words divided with a hyphen However a lot of libraries follow the pattern of Something-Something so this ensures the header is set correctly so libraries like oauthlib can read it --- oauth2_provider/oauth2_backends.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 3ddb9c90b..58860c909 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -1,6 +1,7 @@ import json from urllib.parse import urlparse, urlunparse +from django.http import HttpRequest from oauthlib import oauth2 from oauthlib.common import Request as OauthlibRequest from oauthlib.common import quote, urlencode, urlencoded @@ -75,6 +76,8 @@ def extract_headers(self, request): del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] + if "CONTENT_TYPE" in headers: + headers["Content-Type"] = headers["CONTENT_TYPE"] # Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant, # if the origin is allowed by RequestValidator.is_origin_allowed. # https://github.com/oauthlib/oauthlib/pull/791 From f73ca8c116d72b144b0f4aeda5460c75d370f01e Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:03:52 +0000 Subject: [PATCH 03/28] Add create device authorization response method This method calls the server's create_device_authorization_response method (https://datatracker.ietf.org/doc/html/rfc8628#section-3.2) and is returns to the caller the information adhering to the rfc --- oauth2_provider/oauth2_backends.py | 10 ++++++++++ oauth2_provider/views/mixins.py | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 58860c909..accd9d3f8 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -151,6 +151,16 @@ def create_authorization_response(self, request, scopes, credentials, allow): except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) + def create_device_authorization_response(self, request: HttpRequest): + uri, http_method, body, headers = self._extract_params(request) + try: + headers, body, status = self.server.create_device_authorization_response( + uri, http_method, body, headers + ) + return headers, body, status + except OAuth2Error as exc: + return exc.headers, exc.json, exc.status_code + def create_token_response(self, request): """ A wrapper method that calls create_token_response on `server_class` instance. diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 203d0103b..65af7a09d 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation -from django.http import HttpResponseForbidden, HttpResponseNotFound +from django.http import HttpRequest, HttpResponseForbidden, HttpResponseNotFound from ..exceptions import FatalClientError from ..scopes import get_scopes_backend @@ -114,6 +114,20 @@ def create_authorization_response(self, request, scopes, credentials, allow): core = self.get_oauthlib_core() return core.create_authorization_response(request, scopes, credentials, allow) + def create_device_authorization_response(self, request: HttpRequest): + """ + A wrapper method that calls create_device_authorization_response on `server_class` + instance. + :param request: The current django.http.HttpRequest object + """ + oauth2_settings.EXTRA_SERVER_KWARGS = { + "verification_uri": oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI, + "interval": oauth2_settings.DEVICE_FLOW_INTERVAL, + "user_code_generator": oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR, + } + core = self.get_oauthlib_core() + return core.create_device_authorization_response(request) + def create_token_response(self, request): """ A wrapper method that calls create_token_response on `server_class` instance. From c3abd4a346a88ef144e2b657d1db6c4a0a6daecd Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:06:26 +0000 Subject: [PATCH 04/28] Update the grant type mapping to recognize device code --- oauth2_provider/oauth2_validators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index b20d0dd6c..13fd1c116 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -56,6 +56,7 @@ AbstractApplication.GRANT_CLIENT_CREDENTIALS, AbstractApplication.GRANT_OPENID_HYBRID, ), + "urn:ietf:params:oauth:grant-type:device_code": (AbstractApplication.GRANT_DEVICE_CODE,) } Application = get_application_model() From 9daf17de38a38cc9062a7154d33f0403d91fe29f Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:11:53 +0000 Subject: [PATCH 05/28] Devices that are public should not need basic auth The device flow is initiated by sending the client_id and and a scope. This check should not fail if the client is public --- oauth2_provider/oauth2_validators.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 13fd1c116..7e7c36e91 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -167,6 +167,10 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False + elif (request.client.client_type == "public" + and request.grant_type == "urn:ietf:params:oauth:grant-type:device_code" + ): + return True elif not self._check_secret(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False From 6595265e36e997dc7d379a4e5b65201b9a3da36a Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:41:44 +0000 Subject: [PATCH 06/28] Add device settings OAUTH_DEVICE_VERIFICATION_URI = the uri that comes back from the response so the user knows where to go to. e.g example.com/device OAUTH_DEVICE_USER_CODE_GENERATOR = Allows a custom callable to be passed in to control how the user code is generated, stored in the db and returned back to the caller DEVICE_MODEL = the device model DEVICE_FLOW_INTERVAL = The time in seconds to wait before the device should poll again --- oauth2_provider/settings.py | 8 ++++++++ oauth2_provider/views/mixins.py | 5 ----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 9771aa4e7..88f139d9f 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -28,6 +28,7 @@ USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") +DEVICE_MODEL = getattr(settings, "OAUTH2_PROVIDER_DEVICE_MODEL", "oauth2_provider.Device") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") @@ -39,6 +40,8 @@ "CLIENT_SECRET_GENERATOR_LENGTH": 128, "CLIENT_SECRET_HASHER": "default", "ACCESS_TOKEN_GENERATOR": None, + "OAUTH_DEVICE_VERIFICATION_URI": None, + "OAUTH_DEVICE_USER_CODE_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", @@ -61,6 +64,8 @@ "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, "ID_TOKEN_MODEL": ID_TOKEN_MODEL, + "DEVICE_MODEL": DEVICE_MODEL, + "DEVICE_FLOW_INTERVAL": 5, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin", @@ -268,6 +273,9 @@ def server_kwargs(self): ("refresh_token_expires_in", "REFRESH_TOKEN_EXPIRE_SECONDS"), ("token_generator", "ACCESS_TOKEN_GENERATOR"), ("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"), + ("verification_uri", "OAUTH_DEVICE_VERIFICATION_URI"), + ("interval", "DEVICE_FLOW_INTERVAL"), + ("user_code_generator", "OAUTH_DEVICE_USER_CODE_GENERATOR"), ] } kwargs.update(self.EXTRA_SERVER_KWARGS) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 65af7a09d..be2a77e8d 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -120,11 +120,6 @@ def create_device_authorization_response(self, request: HttpRequest): instance. :param request: The current django.http.HttpRequest object """ - oauth2_settings.EXTRA_SERVER_KWARGS = { - "verification_uri": oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI, - "interval": oauth2_settings.DEVICE_FLOW_INTERVAL, - "user_code_generator": oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR, - } core = self.get_oauthlib_core() return core.create_device_authorization_response(request) From 05fd9f1446edd3b2625ca7f2195ca73daff79632 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:59:32 +0000 Subject: [PATCH 07/28] Create device authorization view This view is to be used in an authorization server in order to provide a /device endpoint --- oauth2_provider/urls.py | 1 + oauth2_provider/views/device.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 oauth2_provider/views/device.py diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 155822f45..5bc9586b6 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -11,6 +11,7 @@ path("token/", views.TokenView.as_view(), name="token"), path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), + path("device_authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization") ] diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py new file mode 100644 index 000000000..41b6097e2 --- /dev/null +++ b/oauth2_provider/views/device.py @@ -0,0 +1,30 @@ +import json + +from django import http +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View +from oauthlib.oauth2 import DeviceApplicationServer + +from oauth2_provider.compat import login_not_required +from oauth2_provider.models import DeviceCodeResponse, DeviceRequest, create_device +from oauth2_provider.views.mixins import OAuthLibMixin + + +@method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") +class DeviceAuthorizationView(OAuthLibMixin, View): + server_class = DeviceApplicationServer + + def post(self, request, *args, **kwargs): + headers, response, status = self.create_device_authorization_response(request) + + device_request = DeviceRequest(client_id=request.POST["client_id"], scope=request.POST.get("scope")) + + if status != 200: + return http.JsonResponse(data=json.loads(response), status=status, headers=headers) + + device_response = DeviceCodeResponse(**response) + create_device(device_request, device_response) + + return http.JsonResponse(data=response, status=status, headers=headers) From 96bb11ad427ecb0ea6cabec909191cc8bf5d38c4 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:44:02 +0000 Subject: [PATCH 08/28] Ensure we import in the views module --- oauth2_provider/views/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 9e32e17d8..cfd554214 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -17,3 +17,4 @@ from .introspect import IntrospectTokenView from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView +from .device import DeviceAuthorizationView From 2a2d959909fb9cc9604ab21cae27ced261878fe1 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:50:46 +0000 Subject: [PATCH 09/28] Migrations --- ...ication_authorization_grant_type_device.py | 41 +++++++++++++++++++ oauth2_provider/models.py | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py diff --git a/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py b/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py new file mode 100644 index 000000000..3996356d3 --- /dev/null +++ b/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.5 on 2025-01-24 14:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0012_add_token_checksum'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44), + ), + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('device_code', models.CharField(max_length=100, unique=True)), + ('user_code', models.CharField(max_length=100)), + ('scope', models.CharField(max_length=64, null=True)), + ('interval', models.IntegerField(default=5)), + ('expires', models.DateTimeField()), + ('status', models.CharField(blank=True, choices=[('authorized', 'Authorized'), ('authorization-pending', 'Authorization pending'), ('expired', 'Expired'), ('denied', 'Denied')], default='authorization-pending', max_length=64)), + ('client_id', models.CharField(db_index=True, max_length=100)), + ('last_checked', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_DEVICE_MODEL', + 'constraints': [models.UniqueConstraint(fields=('device_code',), name='oauth2_provider_device_unique_device_code')], + }, + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index b5d111ac7..1b0fa1f54 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -132,7 +132,7 @@ class AbstractApplication(models.Model): default="", ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) - authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) + authorization_grant_type = models.CharField(max_length=44, choices=GRANT_TYPES) client_secret = ClientSecretField( max_length=255, blank=True, From 07d047350ba1c18200bd336479621b1fddab6c68 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 7 Jan 2025 12:48:41 +0000 Subject: [PATCH 10/28] Add initial stage test --- tests/test_device.py | 102 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/test_device.py diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 000000000..1444030f8 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,102 @@ +from unittest import mock +from urllib.parse import urlencode + +import django.http.response +import pytest +from django.contrib.auth import get_user_model +from django.test import RequestFactory +from django.urls import reverse + +import oauth2_provider.models +from oauth2_provider.models import get_access_token_model, get_application_model, get_device_model + +from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase + + +Application = get_application_model() +AccessToken = get_access_token_model() +UserModel = get_user_model() +DeviceModel: oauth2_provider.models.Device = get_device_model() + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class DeviceFlowBaseTestCase(TestCase): + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( + name="test_client_credentials_app", + user=cls.dev_user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + client_secret="abcdefghijklmnopqrstuvwxyz1234567890", + ) + + +class TestDeviceFlow(DeviceFlowBaseTestCase): + """ + The first 2 tests test the device flow in order + how the device flow works + """ + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_flow_authorization_initiation(self): + """ + Tests the initial stage of the flow when the device sends its device authorization + request to the authorization server. + + Device Authorization Request(https://datatracker.ietf.org/doc/html/rfc8628#section-3.1) + + This request shape: + POST /device_authorization HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + + client_id=1406020730&scope=example_scope + + Should respond with this response shape: + Device Authorization Response (https://datatracker.ietf.org/doc/html/rfc8628#section-3.2) + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "expires_in": 1800, + "interval": 5 + } + """ + + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + + # let's make sure the device was created in the db + assert DeviceModel.objects.get(device_code="abc") + + assert response.json() == { + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": "xyz", + "device_code": "abc", + "interval": 5, + } From 184b19aa8d0254109c657705aa0fe205a0aaa90c Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:20:20 +0000 Subject: [PATCH 11/28] Temp commit: Point oauthlib to master This commit will not be merged(I think). Currently oauthlib is due a release so I'm pointing this to master --- pyproject.toml | 2 +- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 401d33cab..6aefe3481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ dependencies = [ "django >= 4.2", "requests >= 2.13.0", - "oauthlib >= 3.2.2", + "oauthlib @ git+https://github.com/oauthlib/oauthlib.git@master", "jwcrypto >= 1.5.0", ] diff --git a/tox.ini b/tox.ini index 303b0d51d..5c8c266f3 100644 --- a/tox.ini +++ b/tox.ini @@ -41,7 +41,7 @@ deps = dj51: Django>=5.1,<5.2 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework - oauthlib>=3.2.2 + git+https://github.com/oauthlib/oauthlib.git@master#egg=oauthlib jwcrypto coverage pytest @@ -74,7 +74,7 @@ commands = deps = Jinja2<3.1 sphinx<3 - oauthlib>=3.2.2 + git+https://github.com/oauthlib/oauthlib.git@master#egg=oauthlib m2r>=0.2.1 mistune<2 sphinx-rtd-theme From 594ac55ce8fa033b972332fb5e7a10c10df6aa33 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:21:32 +0000 Subject: [PATCH 12/28] Add device poll change test --- tests/test_device.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_device.py b/tests/test_device.py index 1444030f8..7ea03f097 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -100,3 +100,39 @@ def test_device_flow_authorization_initiation(self): "device_code": "abc", "interval": 5, } + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_polling_interval_can_be_changed(self): + """ + Tests the device polling rate(interval) can be changed to something other than the default + of 5 seconds. + """ + + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + + self.oauth2_settings.DEVICE_FLOW_INTERVAL = 10 + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + + assert response.json() == { + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": "xyz", + "device_code": "abc", + "interval": 10, + } From c929a8f724a63e70f0f7a347a562e76fc64446ee Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 7 Jan 2025 13:42:14 +0000 Subject: [PATCH 13/28] Add incorrect client id test --- tests/test_device.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_device.py b/tests/test_device.py index 7ea03f097..7d3ff4954 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -136,3 +136,25 @@ def test_device_polling_interval_can_be_changed(self): "device_code": "abc", "interval": 10, } + + def test_incorrect_client_id_sent(self): + """ + Ensure the correct error is returned when an invalid client is sent + """ + request_data: dict[str, str] = { + "client_id": "client_id_that_does_not_exist", + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + + assert response.json() == { + "error": "invalid_request", + "error_description": "Invalid client_id parameter value.", + } From 0a7cfc500a4a2ef1a8347255a3737bfd4f3a372f Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:57:02 +0000 Subject: [PATCH 14/28] Update authors and changelog --- AUTHORS | 1 + CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index e2da60020..706dd9b2a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -42,6 +42,7 @@ Dave Burkholder David Fischer David Hill David Smith +David Uzumaki Dawid Wolski Diego Garcia Dominik George diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfe6c3e5..8f8fb0c37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added * #1506 Support for Wildcard Origin and Redirect URIs +* Add device authorization grant support WycmWA0U z$MIDJ{1Bf>Ie{IKuq|k>drZ(cb*T@z7SZ7?ca4GNUP?dMY!v!W&T0*1G-;DTKiegn zBRNa%p74_Bo>cNS}Sb~M9CeacW%B@hhaxjI!m+3*&2b9V1dsS(u8ow8vwX<$xC2v9j=t6I zC!fz)1oM?^N{A9SVH?lQB=l+fw1204ic>f}5TAtmML`|_j2ihi=Gv)(PR zAU`l*%N7_oC6?>&$cq4k^@NLa!f($9J*A%m)b{>Wa0ZcU;R=&k+-kp_@So1|e>f#nA|rkD9vQs1$kO&ScIj8q2py#uOX=H=@TlnsEf zO;VkRAd9&HTY-7-YZFj{qFhz>*PqOQ8Llb+DK;2%m?4es{QBdrdI*6m| zWIu15c%J2T_Y?$Xkd&Ew!MEhtq0!oTMgPZ6=zE?k_UZad9*&_9DU!xD!Fp3wi2l=% zVHy-Jd03nAftLArel*G?dg;Vd_;yPuuDX(>=2)i_$q)*zl!{3ZyD(IX%A+!7itsS6 zu}QapnP300x*kZ0=>}yPE#tE0(o*a_sU2-sPK%R35$2(q>TxgA0-s?VU(a%ED6Xl; z3wvd+3tvSIm+y%4hxmcgyly9E?QT35v6?jPo|LgXm3?vb?+SXTLHlh7QbvJz1&d{6 zOrX{BFPcIUGnDnkxZto~+zzkTNxZl(^f)Z?1d?o++?#3j2+!H?A}aJPclQC%A_nwBXQv-)HXb+bSJ6Z=3_ zl8)2kuyquSDt1cn4?ar&@rkl}aJ7{x*FOO1W7#)-!PZaz`RbESO99HtO^}NIJ1*ie z_P4ThcVLoOFMZ`-1oWY2Ney?W=zi2KYu)X69PdWZKv7RweLnwvp- zXn2Sf-Nyx^f|RXSe}}Lzq58aNzB%egO@p26OpT$F3Gpqv0r*bbe51QmcV`uDu}$f? z!s%aeF#zZn@Fmbk9U7x7{5KJ?=nVxVem@d82?DgzzhFD@)R^K8z|GWXukrljSdIdD z`BLD^tHsYVO#p~#($7-;`gChtXPxs9RC{|ili>lx8yEoKloWV?qx(~&H?-90-|@^( zeDg`G7Eq@K0Y6*AKul8YwSX?P3jhim1QbBi^?^8CQMI3k73O2Jl@F+Dh0Ve|gZ@)Q zbao_Chi^delaiP# zs8)%Am;)=zO$b{PJAitn5Q})!#Bmr0hTK2EivYsY<#D}Rz3ziV_5cOhLni?&Wp7ys za343BZ*Wz*>o5C*!U0fQ+)9M#yspEDBx3&vkaw@|>@u8~t7{hUYz92e^1!SGUgDRT~ptk8vc1Hm~jvk%g53`RC) zYs;A+lqqOz4L=<=M>`o%aOvb7)*A2cuH&>c($O@_#u{9~|CVGwVluw7Pb0fHwfmv69LB280tEXJ45a zKm9Nt{#+f%Fn`I%vPgF%53qPQnLDhU=fdTOu6a1sZIoX|B7KlLLUQV)5Ip_F_3?4sR9kN9_$I;U5is$vfn*KR=h0O~Op2Msv|& zz;SuCT|8)2J!kSRl*kZphNbm8Isr`7mx5t4_`*PR0=&6h)g13N9#4_`g6FxT`B(qW zEWkd^2GD9XHN7>azZV6S0Sc@`5mACfLHHC}iq;Vafat@whPwvuAFzIt2cMG?TQ88A zf4CrnN9DLW7kUOT;G`yi0|U3!bOnbd)yD6}iw1!FTo!N-G|87$d^e$KjNx~Rzz-nz zdcuc4#E2M=%77@8nz;ugJGFs=-X2TjBDEY*_~xM9I<`0Ir|3T$oDbtG#SnSt;>I)1 zd%(XwLtK%sr0QgQa#%~i3xF;+NYn#?f|xlx0F;;;<0a2)LS0**mv@xd?KA-W@EPg_ zpn29QY1GinuCSL<@O*pIrM3V>i=ppQWabn4-kutGT!AmRKd+uCed76T@HD5#U@i}E z6+kNn{MJ-i5*>+0+fccjc7BuG|Dccj&&uSaDZwFSD`*8g=dec>7;+Mq1MOmyB=}GV z2jaLe?noWZ92TXQE*#ktPukGf_?|tJOjXH|=O;ijc{!LQI4N}4fjfj+4q%#mqQpg8 z5OObezGV|iO$nkRqaH{aE$kh=A>W)e0GXQrz^_wyrKbEtU|9LE$Tig=ADk~lM`|V??B}(BYrU4=&fQ+~@0Twv#?sO$sLQ!O|%u-2?KE*?+M6rq}+z1(M zbD_EUcn~1?)7palv1BBDBLk`4Cgc1&Sow6{&Z~WhP>uMyu_7+=vjWPUP-Wf&6ojs7 zpy7nJ1xrro;*9u5O``^OW`CST2*{&mp{uzvs!Mf?r}Y5{X1Z!Hk?(e53jP{{X+S-+x7qf<@J(gt?Q@s zKWUG#HNz2N+-g|6Wpd5_llcA>1&AgrwC&#}1^Yf6*=C4p;{PZC6k$NhYfeiMtkW0;-3NCA(b8(P#l6y6lwlFJ4S&N=~o%H|J3c1P66m1^zmZR m|H|?IHs+6T{*RH&JyNwMai#~nCI2CKUs_WBZIQTv@BaW0zjNIH literal 0 HcmV?d00001 diff --git a/docs/_images/device-enter-code-displayed.png b/docs/_images/device-enter-code-displayed.png new file mode 100644 index 0000000000000000000000000000000000000000..201137ce31b646ce3f4f95f15925ea8ef187d9da GIT binary patch literal 18552 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

dO+-avYfi;VnmFFSyMId|H>rvMo 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 Dfn*9b literal 0 HcmV?d00001 diff --git a/docs/tutorial/tutorial.rst b/docs/tutorial/tutorial.rst index 5a0662507..140313673 100644 --- a/docs/tutorial/tutorial.rst +++ b/docs/tutorial/tutorial.rst @@ -9,4 +9,4 @@ Tutorials tutorial_03 tutorial_04 tutorial_05 - + tutorial_06 diff --git a/docs/tutorial/tutorial_06.rst b/docs/tutorial/tutorial_06.rst new file mode 100644 index 000000000..12f189bb5 --- /dev/null +++ b/docs/tutorial/tutorial_06.rst @@ -0,0 +1,100 @@ +Device authorization grant flow +==================================================== + +Scenario +-------- +In :doc:`Part 1 ` you created your own :term:`Authorization Server` and it's running along just fine. +You have devices that your users have, and those users need to authenticate the device against your +:term:`Authorization Server` in order to make the required API calls. + +Device Authorization +-------------------- +The OAuth 2.0 device authorization grant is designed for Internet +connected devices that either lack a browser to perform a user-agent +based authorization or are input-constrained to the extent that +requiring the user to input text in order to authenticate during the +authorization flow is impractical. It enables OAuth clients on such +devices (like smart TVs, media consoles, digital picture frames, and +printers) to obtain user authorization to access protected resources +by using a user agent on a separate device. + +Point your browser to `http://127.0.0.1:8000/o/applications/register/` to create an application. + +Fill the form as shown in the screenshot below, and before saving, take note of the ``Client id``. +Make sure the client type is set to "Public." There are cases where a confidential client makes sense, +but generally, it is assumed the device is unable to safely store the client secret. + +.. image:: _images/application-register-device-code.png + :alt: Device Authorization application registration + +Ensure the setting ``OAUTH_DEVICE_VERIFICATION_URI`` is set to a URI you want to return in the +`verification_uri` key in the response. This is what the device will display to the user. + +1: Navigate to the tests/app/idp directory: + +.. code-block:: sh + + cd tests/app/idp + +then start the server +.. code-block:: sh + + python manage.py runserver + +To initiate device authorization, send this request: + +.. code-block:: sh + + curl --location 'http://127.0.0.1:8000/o/device-authorization/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id={your application\'s client id}' + +The OAuth2 provider will return the following response: + +.. code-block:: json + + { + "verification_uri": "http://127.0.0.1:8000/o/device", + "expires_in": 1800, + "user_code": "A32RVADM", + "device_code": "G30j94v0kNfipD4KmGLTWeL4eZnKHm", + "interval": 5 + } + +Go to `http://127.0.0.1:8000/o/device` in your browser. + +.. image:: _images/device-enter-code-displayed.png + +Enter the code, and it will redirect you to the device-confirm endpoint. + +Device-confirm endpoint +----------------------- +Device polling occurs concurrently while the user approves or denies the request. + +.. image:: _images/device-approve-deny.png + +Device polling +-------------- +Note: You should already have the `/token` endpoint implemented in your authorization server before this. + +Send the following request (in the real world, the device makes this request): + +.. code-block:: sh + + curl --location 'http://localhost:8000/o/token/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'device_code={the device code from the device-authorization response}' \ + --data-urlencode 'client_id={your application\'s client id}' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' + +The response will be similar to this: + +.. code-block:: json + + { + "access_token": "SkJMgyL432P04nHDPyB63DEAM0nVxk", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "openid", + "refresh_token": "Go6VumurDfFAeCeKrpCKPDtElV77id" + } From cbc4b77a31b09881e11842e4204fd940f53ef75d Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:06:51 +0000 Subject: [PATCH 25/28] Update idp requirements Older version doesn't work with newer version of python --- tests/app/idp/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt index 05a8319b5..f59729fdd 100644 --- a/tests/app/idp/requirements.txt +++ b/tests/app/idp/requirements.txt @@ -1,5 +1,5 @@ Django>=4.2,<=5.1 django-cors-headers==4.6.0 -django-environ==0.11.2 +django-environ==0.12.0 -e ../../../ From c2243dccd0b3739c8e85af15b855479f9ad5e95f Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:26:37 +0000 Subject: [PATCH 26/28] Ensure the django user sets the oauthlib request user --- oauth2_provider/models.py | 3 ++- oauth2_provider/settings.py | 6 +++++- oauth2_provider/utils.py | 23 +++++++++++++++++++++++ oauth2_provider/views/device.py | 3 +++ tests/app/idp/idp/settings.py | 4 +++- tests/test_device.py | 23 ++++++++++++++++++++++- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1b0fa1f54..a41a3d80f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from datetime import timezone as dt_timezone -from typing import Optional +from typing import Callable, Optional, Union from urllib.parse import parse_qsl, urlparse from django.apps import apps @@ -734,6 +734,7 @@ class DeviceCodeResponse: user_code: int device_code: str interval: int + verification_uri_complete: Optional[Union[str, Callable]] = None def create_device(device_request: DeviceRequest, device_response: DeviceCodeResponse) -> Device: diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 5216c806f..45b2a5895 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -24,7 +24,7 @@ from django.utils.module_loading import import_string from oauthlib.common import Request -from oauth2_provider.utils import user_code_generator +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user, user_code_generator USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) @@ -43,7 +43,9 @@ "CLIENT_SECRET_HASHER": "default", "ACCESS_TOKEN_GENERATOR": None, "OAUTH_DEVICE_VERIFICATION_URI": None, + "OAUTH_DEVICE_VERIFICATION_URI_COMPLETE": None, "OAUTH_DEVICE_USER_CODE_GENERATOR": user_code_generator, + "OAUTH_PRE_TOKEN_VALIDATION": [set_oauthlib_user_to_device_request_user], "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", @@ -276,8 +278,10 @@ def server_kwargs(self): ("token_generator", "ACCESS_TOKEN_GENERATOR"), ("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"), ("verification_uri", "OAUTH_DEVICE_VERIFICATION_URI"), + ("verification_uri_complete", "OAUTH_DEVICE_VERIFICATION_URI_COMPLETE"), ("interval", "DEVICE_FLOW_INTERVAL"), ("user_code_generator", "OAUTH_DEVICE_USER_CODE_GENERATOR"), + ("pre_token", "OAUTH_PRE_TOKEN_VALIDATION"), ] } kwargs.update(self.EXTRA_SERVER_KWARGS) diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py index ef213dcac..4d6138685 100644 --- a/oauth2_provider/utils.py +++ b/oauth2_provider/utils.py @@ -3,6 +3,7 @@ from django.conf import settings from jwcrypto import jwk +from oauthlib.common import Request @functools.lru_cache() @@ -75,3 +76,25 @@ def user_code_generator(user_code_length: int = 8) -> str: user_code[i] = random.choice(character_space) return "".join(user_code) + + +def set_oauthlib_user_to_device_request_user(request: Request) -> None: + """ + The user isn't known when the device flow is initiated by a device. + All we know is the client_id. + + However, when the user logins in order to submit the user code + from the device we now know which user is trying to authenticate + their device. We update the device user field at this point + and save it in the db. + + This function is added to the pre_token stage during the device code grant's + create_token_response where we have the oauthlib Request object which is what's used + to populate the user field in the device model + """ + # Since this function is used in the settings module, it will lead to circular imports + # since django isn't fully initialised yet when settings run + from oauth2_provider.models import Device, get_device_model + + device: Device = get_device_model().objects.get(device_code=request._params["device_code"]) + request.user = device.user diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 676dfbf90..075b9bc10 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -59,6 +59,9 @@ def device_user_code_view(request): user_code: str = form.cleaned_data["user_code"] device: Device = get_device_model().objects.get(user_code=user_code) + device.user = request.user + device.save(update_fields=["user"]) + if device is None: form.add_error("user_code", "Incorrect user code") return render(request, "oauth2_provider/device/user_code.html", {"form": form}) diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py index f92ba2b5e..679407604 100644 --- a/tests/app/idp/idp/settings.py +++ b/tests/app/idp/idp/settings.py @@ -15,7 +15,7 @@ import environ -from oauth2_provider.utils import user_code_generator +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user, user_code_generator # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -202,7 +202,9 @@ OAUTH2_PROVIDER = { "OAUTH2_VALIDATOR_CLASS": "idp.oauth.CustomOAuth2Validator", "OAUTH_DEVICE_VERIFICATION_URI": "http://127.0.0.1:8000/o/device", + "OAUTH_PRE_TOKEN_VALIDATION": [set_oauthlib_user_to_device_request_user], "OAUTH_DEVICE_USER_CODE_GENERATOR": user_code_generator, + "OAUTH_DEVICE_VERIFICATION_URI_COMPLETE": lambda x: f"http://127.0.0.1:8000/o/device?user_code={x}", "OIDC_ENABLED": env("OAUTH2_PROVIDER_OIDC_ENABLED"), "OIDC_RP_INITIATED_LOGOUT_ENABLED": env("OAUTH2_PROVIDER_OIDC_RP_INITIATED_LOGOUT_ENABLED"), # this key is just for out test app, you should never store a key like this in a production environment. diff --git a/tests/test_device.py b/tests/test_device.py index 345552a23..18f1fce39 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -8,7 +8,13 @@ from django.urls import reverse import oauth2_provider.models -from oauth2_provider.models import get_access_token_model, get_application_model, get_device_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_device_model, + get_refresh_token_model, +) +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user from . import presets from .common_testing import OAuth2ProviderTestCase as TestCase @@ -16,6 +22,7 @@ Application = get_application_model() AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() UserModel = get_user_model() DeviceModel: oauth2_provider.models.Device = get_device_model() @@ -122,6 +129,8 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): # ----------------------- self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + self.oauth2_settings.OAUTH_PRE_TOKEN_VALIDATION = [set_oauthlib_user_to_device_request_user] request_data: dict[str, str] = { "client_id": self.application.client_id, @@ -193,6 +202,7 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): "client_id": self.application.client_id, "grant_type": "urn:ietf:params:oauth:grant-type:device_code", } + token_response = self.client.post( "/o/token/", data=urlencode(token_payload), @@ -207,6 +217,17 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): assert token_data["token_type"].lower() == "bearer" assert "scope" in token_data + # ensure the access token and refresh token have the same user as the device that just authenticated + access_token: oauth2_provider.models.AccessToken = AccessToken.objects.get( + token=token_data["access_token"] + ) + assert access_token.user == device.user + + refresh_token: oauth2_provider.models.RefreshToken = RefreshToken.objects.get( + token=token_data["refresh_token"] + ) + assert refresh_token.user == device.user + @mock.patch( "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", lambda: "abc", From cc328bad6aed8056a9c4e16c0127552b16bc103c Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Wed, 29 Jan 2025 23:57:25 +0000 Subject: [PATCH 27/28] Ensure device token errors are returning 400 --- oauth2_provider/views/base.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 7d12f3277..d3f30e400 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -323,10 +323,18 @@ def device_flow_token_response( device = Device.objects.get(device_code=device_code) if device.status == device.AUTHORIZATION_PENDING: - raise AuthorizationPendingError + pending_error = AuthorizationPendingError() + return http.HttpResponse( + content=pending_error.json, status=pending_error.status_code, content_type="application/json" + ) if device.status == device.DENIED: - raise AccessDenied + access_denied_error = AccessDenied() + return http.HttpResponse( + content=access_denied_error.json, + status=access_denied_error.status_code, + content_type="application/json", + ) url, headers, body, status = self.create_token_response(request) From 5039617a693ea7f961fdd542209c0c1575431a36 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:07:47 +0000 Subject: [PATCH 28/28] Add device code to refresh token mapping --- oauth2_provider/oauth2_validators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index b89ad039a..ad017af52 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -52,6 +52,7 @@ "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_DEVICE_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, AbstractApplication.GRANT_OPENID_HYBRID,