From 5e95ea2457cada574dd4536e7de3525dd49e869a 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/41] 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 a439a3125b7e2e654ddbb66a9bf52b8427f777a2 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/41] 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 09c78578584dff10c95a246eb50a9465f5dd6dc6 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/41] 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 a98c83dd77c644cbccaa38bba8c65eb2acbe560f 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/41] 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 db459a446..6bf45978f 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 123b548df75f8934ff6baacb87caeba81cb7cd02 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/41] 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 6bf45978f..fc9e1b62d 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 18317a245edf8bd8e631321c4125cfe380cf8daf 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/41] 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 00295832aefec7cea51d44e2ffeca8494433f054 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/41] 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 3fe8719e2c6817eb1483bd663ddae0a9b19d21d2 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/41] 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 84d5f14a97dbb78aacb3e14ebd6d647b3e1e9357 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/41] 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 284fa4aca133f8a3b1035ee69f0fad60b18b6a11 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/41] 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 1fc9856591d8a8ff835dce26472f64f2a74cce5e 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/41] 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 27bdfb585..0703333b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,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 0a85f5fb8..b15f8af23 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,7 @@ deps = dj52: Django>=5.2,<6.0 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 @@ -79,7 +79,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 9f31e23b88a115ef8080d58d4b59f253148bb167 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/41] 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 16b9ea10df47660decf4dd5816ef41ec8f7c6ccb 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/41] 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 1a843c573b0206442beb66cc494699b684caeba2 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/41] Update authors and changelog --- AUTHORS | 2 ++ CHANGELOG.md | 1 + 2 files changed, 3 insertions(+) diff --git a/AUTHORS b/AUTHORS index 2d3f80527..2d8d5465b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,6 +36,7 @@ Bas van Oostveen Brian Helba Carl Schwan Cihad GUNDOGDU +Cristian Prigoana Daniel Golding Daniel 'Vector' Kerr Darrel O'Pry @@ -43,6 +44,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 f2d44ba4f..1821a8ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1506 Support for Wildcard Origin and Redirect URIs - Adds a new setting [ALLOW_URL_WILDCARDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#allow-uri-wildcards). This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch deployments for development previews and user acceptance testing. * #1586 Turkish language support added +* #1539 Add device authorization grant support ### Changed The project is now hosted in the django-oauth organization. From 6a44ab18c390b4cbbcf6f07a3ed419acb8cfdf98 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:02:24 +0000 Subject: [PATCH 15/41] Add the device user code form --- .../oauth2_provider/device/user_code.html | 16 ++++++ oauth2_provider/urls.py | 3 +- oauth2_provider/views/device.py | 52 ++++++++++++++++++- tests/app/idp/templates/device/user_code.html | 16 ++++++ 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 oauth2_provider/templates/oauth2_provider/device/user_code.html create mode 100644 tests/app/idp/templates/device/user_code.html diff --git a/oauth2_provider/templates/oauth2_provider/device/user_code.html b/oauth2_provider/templates/oauth2_provider/device/user_code.html new file mode 100644 index 000000000..774b95897 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/device/user_code.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Device code + + +

Enter code displayed on device

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ + +{% endblock content %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 5bc9586b6..89eaa86ea 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -11,7 +11,8 @@ 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") + path("device-authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization"), + path("device/", views.device_user_code_view, name="device"), ] diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 41b6097e2..5869aacf2 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -1,13 +1,20 @@ import json -from django import http +from django import forms, http +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from django.urls import reverse 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 oauthlib.oauth2.rfc8628.errors import ( + AccessDenied, + ExpiredTokenError, +) from oauth2_provider.compat import login_not_required -from oauth2_provider.models import DeviceCodeResponse, DeviceRequest, create_device +from oauth2_provider.models import Device, DeviceCodeResponse, DeviceRequest, create_device, get_device_model from oauth2_provider.views.mixins import OAuthLibMixin @@ -28,3 +35,44 @@ def post(self, request, *args, **kwargs): create_device(device_request, device_response) return http.JsonResponse(data=response, status=status, headers=headers) + + +class DeviceForm(forms.Form): + user_code = forms.CharField(required=True) + + +# it's common to see in real world products +# device flow's only asking the user to sign in after they input the +# user code but since the user has to be signed in regardless to approve the +# device login we're making the decision here to require being logged in +# up front +@login_required +def device_user_code_view(request): + form = DeviceForm(request.POST) + + if request.method != "POST": + return render(request, "oauth2_provider/device/user_code.html", {"form": form}) + + if not form.is_valid(): + return render(request, "oauth2_provider/device/user_code.html", {"form": form}) + + user_code: str = form.cleaned_data["user_code"] + device: Device = get_device_model().objects.get(user_code=user_code) + + if device is None: + form.add_error("user_code", "Incorrect user code") + return render(request, "oauth2_provider/device/user_code.html", {"form": form}) + + if device.is_expired(): + device.status = device.EXPIRED + device.save(update_fields=["status"]) + raise ExpiredTokenError + + # User of device has already made their decision for this device + if device.status in (device.DENIED, device.AUTHORIZED): + raise AccessDenied + + # 308 to indicate we want to keep the redirect being a POST request + return http.HttpResponsePermanentRedirect( + reverse("oauth2_provider:device-confirm", kwargs={"device_code": device.device_code}), status=308 + ) diff --git a/tests/app/idp/templates/device/user_code.html b/tests/app/idp/templates/device/user_code.html new file mode 100644 index 000000000..774b95897 --- /dev/null +++ b/tests/app/idp/templates/device/user_code.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Device code + + +

Enter code displayed on device

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ + +{% endblock content %} From 793874b8e7e957ecb14f19ef5c57e7484ff41554 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:16:53 +0000 Subject: [PATCH 16/41] Add approve deny form --- .../oauth2_provider/device/accept_deny.html | 16 ++++++++++++++ oauth2_provider/urls.py | 1 + oauth2_provider/views/device.py | 21 +++++++++++++++++++ .../app/idp/templates/device/accept_deny.html | 16 ++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 oauth2_provider/templates/oauth2_provider/device/accept_deny.html create mode 100644 tests/app/idp/templates/device/accept_deny.html diff --git a/oauth2_provider/templates/oauth2_provider/device/accept_deny.html b/oauth2_provider/templates/oauth2_provider/device/accept_deny.html new file mode 100644 index 000000000..4fd31a6fb --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/device/accept_deny.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Accept or Deny + + +

Please choose an action:

+
+ {% csrf_token %} + + +
+ + +{% endblock content %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 89eaa86ea..448d2c1ba 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -13,6 +13,7 @@ path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), path("device-authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization"), path("device/", views.device_user_code_view, name="device"), + path("device-confirm/", views.device_confirm_view, name="device-confirm"), ] diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 5869aacf2..676dfbf90 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -76,3 +76,24 @@ def device_user_code_view(request): return http.HttpResponsePermanentRedirect( reverse("oauth2_provider:device-confirm", kwargs={"device_code": device.device_code}), status=308 ) + + +@login_required +def device_confirm_view(request: http.HttpRequest, device_code: str): + device: Device = get_device_model().objects.get(device_code=device_code) + + if device.status in (device.AUTHORIZED, device.DENIED): + return http.HttpResponse("Invalid") + + action = request.POST.get("action") + + if action == "accept": + device.status = device.AUTHORIZED + device.save(update_fields=["status"]) + return http.HttpResponse("approved") + elif action == "deny": + device.status = device.DENIED + device.save(update_fields=["status"]) + return http.HttpResponse("deny") + + return render(request, "oauth2_provider/device/accept_deny.html") diff --git a/tests/app/idp/templates/device/accept_deny.html b/tests/app/idp/templates/device/accept_deny.html new file mode 100644 index 000000000..4fd31a6fb --- /dev/null +++ b/tests/app/idp/templates/device/accept_deny.html @@ -0,0 +1,16 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Accept or Deny + + +

Please choose an action:

+
+ {% csrf_token %} + + +
+ + +{% endblock content %} From 96965a9033e36dc1e564977390ff468ee2f8caa1 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:19:24 +0000 Subject: [PATCH 17/41] Update request body validator A public device code grant doesn't have a client_secret to check --- oauth2_provider/oauth2_validators.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index fc9e1b62d..fcf2c7a61 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -56,7 +56,7 @@ AbstractApplication.GRANT_CLIENT_CREDENTIALS, AbstractApplication.GRANT_OPENID_HYBRID, ), - "urn:ietf:params:oauth:grant-type:device_code": (AbstractApplication.GRANT_DEVICE_CODE,) + "urn:ietf:params:oauth:grant-type:device_code": (AbstractApplication.GRANT_DEVICE_CODE,), } Application = get_application_model() @@ -167,8 +167,9 @@ 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" + 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): @@ -196,6 +197,11 @@ def _authenticate_request_body(self, request): if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % 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 body auth: wrong client secret %s" % client_secret) return False From 1d426eeff63d21afce4bbee338f364aaa3bf141a Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:22:50 +0000 Subject: [PATCH 18/41] Update device imports --- oauth2_provider/views/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index cfd554214..30ebb0bc7 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -17,4 +17,4 @@ from .introspect import IntrospectTokenView from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView -from .device import DeviceAuthorizationView +from .device import DeviceAuthorizationView, device_user_code_view, device_confirm_view From 54e66efa0f154b2885f503d093a783240770a798 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 15:59:17 +0000 Subject: [PATCH 19/41] Update token endpoint It needs handled differently depending on the device grant type or not it also needs to be rate limited to adhrere to the polling section in the spec so a device can't spam the token endpoint --- oauth2_provider/views/base.py | 45 ++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index c5c904b14..95de114e3 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -3,6 +3,7 @@ import logging from urllib.parse import parse_qsl, urlencode, urlparse +from django import http from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse @@ -12,6 +13,13 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +from django_ratelimit.decorators import ratelimit +from oauthlib.oauth2.rfc8628.errors import ( + AccessDenied, + AuthorizationPendingError, +) + +from oauth2_provider.models import Device from ..compat import login_not_required from ..exceptions import OAuthToolkitError @@ -290,10 +298,13 @@ class TokenView(OAuthLibMixin, View): * Authorization code * Password * Client credentials + * Device code flow (specifically for the device polling stage) """ @method_decorator(sensitive_post_parameters("password", "client_secret")) - def post(self, request, *args, **kwargs): + def authorization_flow_token_response( + self, request: http.HttpRequest, *args, **kwargs + ) -> http.HttpResponse: url, headers, body, status = self.create_token_response(request) if status == 200: access_token = json.loads(body).get("access_token") @@ -307,6 +318,38 @@ def post(self, request, *args, **kwargs): response[k] = v return response + @method_decorator(ratelimit(key="ip", rate=f"1/{oauth2_settings.DEVICE_FLOW_INTERVAL}")) + def device_flow_token_response( + self, request: http.HttpRequest, device_code: str, *args, **kwargs + ) -> http.HttpResponse: + device = Device.objects.get(device_code=device_code) + + if device.status == device.AUTHORIZATION_PENDING: + raise AuthorizationPendingError + + if device.status == device.DENIED: + raise AccessDenied + + url, headers, body, status = self.create_token_response(request) + + if status != 200: + return http.JsonResponse(data=json.loads(body), status=status) + + response = http.JsonResponse(data=json.loads(body), status=status) + + for k, v in headers.items(): + response[k] = v + + device.status = device.EXPIRED + device.save(update_fields=["status"]) + return response + + def post(self, request: http.HttpRequest, *args, **kwargs) -> http.HttpResponse: + params = request.POST + if params.get("grant_type") == "urn:ietf:params:oauth:grant-type:device_code": + return self.device_flow_token_response(request, params["device_code"]) + return self.authorization_flow_token_response(request) + @method_decorator(csrf_exempt, name="dispatch") @method_decorator(login_not_required, name="dispatch") From d7d802e5c7aaaf7cea1fa59b4bf825a68c9a014d Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:07:01 +0000 Subject: [PATCH 20/41] Prep the tests: Create user_code_generator util This creates a user friendly but still high entropy user code to be used in the device flow --- oauth2_provider/utils.py | 43 +++++++++++++++++++++++++++++++++++ tests/app/idp/idp/settings.py | 4 ++++ 2 files changed, 47 insertions(+) diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py index 3f48723c5..ef213dcac 100644 --- a/oauth2_provider/utils.py +++ b/oauth2_provider/utils.py @@ -1,4 +1,5 @@ import functools +import random from django.conf import settings from jwcrypto import jwk @@ -32,3 +33,45 @@ def get_timezone(time_zone): return pytz.timezone(time_zone) return zoneinfo.ZoneInfo(time_zone) + + +def user_code_generator(user_code_length: int = 8) -> str: + """ + Recommended user code that retains enough entropy but doesn't + ruin the user experience of typing the code in. + + the below is based off: + https://datatracker.ietf.org/doc/html/rfc8628#section-5.1 + but with added explanation as to where 34.5 bits of entropy is coming from + + entropy (in bits) = length of user code * log2(length of set of chars) + e = 8 * log2(20) + e = 34.5 + + log2(20) is used here to say "you can make 20 yes/no decisions per user code single input character". + + _ _ _ _ - _ _ _ _ = 20^8 ~= 2^35.5 + * + + * you have 20 choices of chars to choose from (20 yes no decisions) + and so on for the other 7 spaces + + in english this means an attacker would need to try + 2^34.5 unique combinations to exhaust all possibilities. + however with a user code only being valid for 30 seconds + and rate limiting, a brute force attack is extremely unlikely + to work + + for our function we'll be using a base 32 character set + """ + + # base32 character space + character_space = "0123456789ABCDEFGHIJKLMNOPQRSTUV" + + # being explicit with length + user_code = [""] * user_code_length + + for i in range(user_code_length): + user_code[i] = random.choice(character_space) + + return "".join(user_code) diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py index eee20982e..f92ba2b5e 100644 --- a/tests/app/idp/idp/settings.py +++ b/tests/app/idp/idp/settings.py @@ -15,6 +15,8 @@ import environ +from oauth2_provider.utils import user_code_generator + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -199,6 +201,8 @@ OAUTH2_PROVIDER = { "OAUTH2_VALIDATOR_CLASS": "idp.oauth.CustomOAuth2Validator", + "OAUTH_DEVICE_VERIFICATION_URI": "http://127.0.0.1:8000/o/device", + "OAUTH_DEVICE_USER_CODE_GENERATOR": user_code_generator, "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. From 735db9ff47e2a9fa31e83322d4fa880caec389d0 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:34:01 +0000 Subject: [PATCH 21/41] Add user code generator to main settings --- oauth2_provider/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 88f139d9f..5216c806f 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -24,6 +24,8 @@ from django.utils.module_loading import import_string from oauthlib.common import Request +from oauth2_provider.utils import user_code_generator + USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) @@ -41,7 +43,7 @@ "CLIENT_SECRET_HASHER": "default", "ACCESS_TOKEN_GENERATOR": None, "OAUTH_DEVICE_VERIFICATION_URI": None, - "OAUTH_DEVICE_USER_CODE_GENERATOR": None, + "OAUTH_DEVICE_USER_CODE_GENERATOR": user_code_generator, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", From f94b63db986b78eb54d06993c8e307e0c0915dad Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:35:18 +0000 Subject: [PATCH 22/41] Add tests to test the whole flow Tests the device flow end to end --- tests/test_device.py | 108 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/tests/test_device.py b/tests/test_device.py index 7d3ff4954..345552a23 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -34,7 +34,7 @@ def setUpTestData(cls): name="test_client_credentials_app", user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, - authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + authorization_grant_type=Application.GRANT_DEVICE_CODE, client_secret="abcdefghijklmnopqrstuvwxyz1234567890", ) @@ -101,6 +101,112 @@ def test_device_flow_authorization_initiation(self): "interval": 5, } + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_flow_authorization_user_code_confirm_and_access_token(self): + """ + 1. User visits the /device endpoint in their browsers and submits the user code + + the device and approve deny actions occur concurrently + (i.e the device is polling the token endpoint while the user + either approves or denies the device) + + -2(3)-. User approves or denies the device + -3(2)-. Device polls the /token endpoint + """ + + # ----------------------- + # 0: Setup device flow + # ----------------------- + 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) + + 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", + ) + + # /device and /device_confirm require a user to be logged in + # to access it + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + # -------------------------------------------------------------------------------- + # 1. User visits the /device endpoint in their browsers and submits the user code + # submits wrong code then right code + # -------------------------------------------------------------------------------- + + # 1. User visits the /device endpoint in their browsers and submits the user code + # (GET Request to load it) + get_response = self.client.get(reverse("oauth2_provider:device")) + assert get_response.status_code == 200 + assert "form" in get_response.context # Ensure the form is rendered in the context + + # 1.1.0 User visits the /device endpoint in their browsers and submits wrong user code + with pytest.raises(oauth2_provider.models.Device.DoesNotExist): + self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "invalid_code"}, + ) + + # 1.1.1: user submits valid user code + post_response_valid = self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "xyz"}, + ) + + device_confirm_url = reverse("oauth2_provider:device-confirm", kwargs={"device_code": "abc"}) + assert post_response_valid.status_code == 308 # Ensure it redirects with 308 status + assert post_response_valid["Location"] == device_confirm_url + + # -------------------------------------------------------------------------------- + # 2: We redirect to the accept/deny form (the user is still in their browser) + # and approves + # -------------------------------------------------------------------------------- + get_confirm = self.client.get(device_confirm_url) + assert get_confirm.status_code == 200 + + approve_response = self.client.post(device_confirm_url, data={"action": "accept"}) + assert approve_response.status_code == 200 + assert approve_response.content.decode() == "approved" + + device = DeviceModel.objects.get(device_code="abc") + assert device.status == device.AUTHORIZED + + # ------------------------- + # 3: Device polls /token + # ------------------------- + token_payload = { + "device_code": device.device_code, + "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), + content_type="application/x-www-form-urlencoded", + ) + + assert token_response.status_code == 200 + + token_data = token_response.json() + + assert "access_token" in token_data + assert token_data["token_type"].lower() == "bearer" + assert "scope" in token_data + @mock.patch( "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", lambda: "abc", From 83e5f7c7678ca707e978d37d7a843b490edebd93 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:06:07 +0000 Subject: [PATCH 23/41] Update idp requirements --- oauth2_provider/views/base.py | 2 -- tests/app/idp/requirements.txt | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 95de114e3..7d12f3277 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -13,7 +13,6 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View -from django_ratelimit.decorators import ratelimit from oauthlib.oauth2.rfc8628.errors import ( AccessDenied, AuthorizationPendingError, @@ -318,7 +317,6 @@ def authorization_flow_token_response( response[k] = v return response - @method_decorator(ratelimit(key="ip", rate=f"1/{oauth2_settings.DEVICE_FLOW_INTERVAL}")) def device_flow_token_response( self, request: http.HttpRequest, device_code: str, *args, **kwargs ) -> http.HttpResponse: diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt index f607463d7..fa41c7d2c 100644 --- a/tests/app/idp/requirements.txt +++ b/tests/app/idp/requirements.txt @@ -1,5 +1,5 @@ Django>=4.2,<=5.2 -django-cors-headers==3.14.0 +django-cors-headers==4.6.0 django-environ==0.11.2 -e ../../../ From 2d66b40bb861adf935d6c5c03ab07fbd7585f513 Mon Sep 17 00:00:00 2001 From: David Uzumaki <56260075+duzumaki@users.noreply.github.com> Date: Tue, 7 Jan 2025 15:47:03 +0000 Subject: [PATCH 24/41] Add tutotial doc --- .../application-register-device-code.png | Bin 0 -> 57112 bytes docs/_images/device-approve-deny.png | Bin 0 -> 16856 bytes docs/_images/device-enter-code-displayed.png | Bin 0 -> 18552 bytes docs/tutorial/tutorial.rst | 2 +- docs/tutorial/tutorial_06.rst | 100 ++++++++++++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 docs/_images/application-register-device-code.png create mode 100644 docs/_images/device-approve-deny.png create mode 100644 docs/_images/device-enter-code-displayed.png create mode 100644 docs/tutorial/tutorial_06.rst diff --git a/docs/_images/application-register-device-code.png b/docs/_images/application-register-device-code.png new file mode 100644 index 0000000000000000000000000000000000000000..4eac6d2628fe50b0bbde67d594306904040446a2 GIT binary patch literal 57112 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≺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 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 a924efb8a06dfa20f94dbee7b10f08c025f9f084 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/41] 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 fa41c7d2c..f8d653aba 100644 --- a/tests/app/idp/requirements.txt +++ b/tests/app/idp/requirements.txt @@ -1,5 +1,5 @@ Django>=4.2,<=5.2 django-cors-headers==4.6.0 -django-environ==0.11.2 +django-environ==0.12.0 -e ../../../ From 02ea665df14469b92d52bc5985547dce2dac7ea3 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/41] 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 32a329ed98f6c52811dfdca4c2c67ca8b9a93e7e 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/41] 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 14e2619d5070629bc7cdc98a8e17fd4b5360c2ba 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/41] 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 fcf2c7a61..ec974b0c6 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, From 571ea33d9f2d41a94607a895bf96a42ef20ef12b Mon Sep 17 00:00:00 2001 From: Darrel O'Pry Date: Mon, 19 May 2025 11:55:32 -0400 Subject: [PATCH 29/41] chore: address documentation notes per https://github.com/cristiprg --- docs/tutorial/tutorial_06.rst | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/tutorial/tutorial_06.rst b/docs/tutorial/tutorial_06.rst index 12f189bb5..2d68b8187 100644 --- a/docs/tutorial/tutorial_06.rst +++ b/docs/tutorial/tutorial_06.rst @@ -1,4 +1,4 @@ -Device authorization grant flow +Part 6 - Device authorization grant flow ==================================================== Scenario @@ -24,7 +24,7 @@ Fill the form as shown in the screenshot below, and before saving, take note of 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 +.. 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 @@ -47,7 +47,7 @@ To initiate device authorization, send this request: 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}' + --data-urlencode 'client_id={your application client id}' The OAuth2 provider will return the following response: @@ -63,7 +63,7 @@ The OAuth2 provider will return the following response: Go to `http://127.0.0.1:8000/o/device` in your browser. -.. image:: _images/device-enter-code-displayed.png +.. image:: ../_images/device-enter-code-displayed.png Enter the code, and it will redirect you to the device-confirm endpoint. @@ -71,12 +71,10 @@ Device-confirm endpoint ----------------------- Device polling occurs concurrently while the user approves or denies the request. -.. image:: _images/device-approve-deny.png +.. 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 From ed4a2fb57687e62f65123b0ab0459ebc39dee2b3 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Thu, 22 May 2025 15:42:42 +0100 Subject: [PATCH 30/41] chore: add more details to the tutorial docs --- docs/tutorial/tutorial_06.rst | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/tutorial/tutorial_06.rst b/docs/tutorial/tutorial_06.rst index 2d68b8187..22f0e1e8e 100644 --- a/docs/tutorial/tutorial_06.rst +++ b/docs/tutorial/tutorial_06.rst @@ -30,18 +30,22 @@ but generally, it is assumed the device is unable to safely store the client sec 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: +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: +.. _RFC: https://www.rfc-editor.org/rfc/rfc8628 + +2. To initiate device authorization, send this request (in the real world, the device +makes this request). In `RFC`_ Figure 1, this is step (A). .. code-block:: sh @@ -49,7 +53,7 @@ To initiate device authorization, send this request: --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={your application client id}' -The OAuth2 provider will return the following response: +The OAuth2 provider will return the following response. In `RFC`_ Figure 1, this is step (B). .. code-block:: json @@ -61,31 +65,41 @@ The OAuth2 provider will return the following response: "interval": 5 } -Go to `http://127.0.0.1:8000/o/device` in your browser. +In the real world, the device will somehow make the value of the `user_code` available to the user (either on-screen display, +or Bluetooth, NFC, etc.). In `RFC`_ Figure 1, this is step (C). + +3. 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. +Enter the code, and it will redirect you to the device-confirm endpoint. In `RFC`_ Figure 1, this is step (D). Device-confirm endpoint ----------------------- -Device polling occurs concurrently while the user approves or denies the request. +4. Device polling occurs concurrently while the user approves or denies the request. .. image:: ../_images/device-approve-deny.png Device polling -------------- -Send the following request (in the real world, the device makes this request): +Send the following request (in the real world, the device makes this request). In `RFC`_ Figure 1, this is step (E). .. 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 'client_id={your application client id}' \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' -The response will be similar to this: +In `RFC`_ Figure 1, there are two options for step (F). Until the user enters the code in the browser and approves, +the response will be 400: + +.. code-block:: json + + {"error": "authorization_pending"} + +After the user approves, the response will be 200: .. code-block:: json From 4e8063f357eb8eadb6718d97e7b8dbc6f31d6ad5 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Fri, 23 May 2025 16:12:57 +0100 Subject: [PATCH 31/41] fix: addressed comments; enhanced preconditions and responses of views In this commit I've addressed a few issues raised in comments about the flow. * Always return informative errors to user-facing views. If the device is not in the expected state, the errors are returned in the form, instead of raising exceptions. * Always return JSON response to device-facing view (aka TokenView). If the device is not in the expected state, the errors are returned according to the RFC. * Never involve device_code in frontend. The redirect to device-confirm view now takes client_id and user_code arguments instead of device_code * Increased test coverage. Added tests for expected error cases handled in the code --- docs/tutorial/tutorial_06.rst | 20 ++- oauth2_provider/models.py | 13 +- oauth2_provider/urls.py | 2 +- oauth2_provider/views/base.py | 51 ++++-- oauth2_provider/views/device.py | 68 +++++--- tests/app/README.md | 4 +- tests/test_device.py | 298 ++++++++++++++++++++++++++++++-- 7 files changed, 394 insertions(+), 62 deletions(-) diff --git a/docs/tutorial/tutorial_06.rst b/docs/tutorial/tutorial_06.rst index 22f0e1e8e..386e4ef39 100644 --- a/docs/tutorial/tutorial_06.rst +++ b/docs/tutorial/tutorial_06.rst @@ -43,6 +43,7 @@ then start the server python manage.py runserver .. _RFC: https://www.rfc-editor.org/rfc/rfc8628 +.. _RFC section 3.5: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 2. To initiate device authorization, send this request (in the real world, the device makes this request). In `RFC`_ Figure 1, this is step (A). @@ -92,14 +93,27 @@ Send the following request (in the real world, the device makes this request). I --data-urlencode 'client_id={your application client id}' \ --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' -In `RFC`_ Figure 1, there are two options for step (F). Until the user enters the code in the browser and approves, -the response will be 400: +In `RFC`_ Figure 1, there are multiple options for step (F), as per `RFC section 3.5`_. Until the user enters the code +in the browser and approves, the response will be 400: .. code-block:: json {"error": "authorization_pending"} -After the user approves, the response will be 200: +Or if the user has denied the device, the response is 400: + +.. code-block:: json + + {"error": "access_denied"} + +Or if the token has expired, the response is 400: + +.. code-block:: json + + {"error": "expired_token"} + + +However, after the user approves, the response will be 200: .. code-block:: json diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a41a3d80f..d5dce6cdc 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -698,10 +698,19 @@ class Meta: def is_expired(self): """ - Check device flow session expiration. + Check device flow session expiration and set the status to "expired" if current time + is past the "expires" deadline. """ + if self.status == self.EXPIRED: + return True + now = datetime.now(tz=dt_timezone.utc) - return now >= self.expires + if now >= self.expires: + self.status = self.EXPIRED + self.save(update_fields=["status"]) + return True + + return False class DeviceManager(models.Manager): diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 448d2c1ba..41b32a306 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -13,7 +13,7 @@ path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), path("device-authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization"), path("device/", views.device_user_code_view, name="device"), - path("device-confirm/", views.device_confirm_view, name="device-confirm"), + path("device-confirm//", views.device_confirm_view, name="device-confirm"), ] diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index d3f30e400..358bfbf5c 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -13,10 +13,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View -from oauthlib.oauth2.rfc8628.errors import ( - AccessDenied, - AuthorizationPendingError, -) +from oauthlib.oauth2.rfc8628 import errors as rfc8628_errors from oauth2_provider.models import Device @@ -320,34 +317,56 @@ def authorization_flow_token_response( def device_flow_token_response( self, request: http.HttpRequest, device_code: str, *args, **kwargs ) -> http.HttpResponse: - device = Device.objects.get(device_code=device_code) + try: + device = Device.objects.get(device_code=device_code) + except Device.DoesNotExist: + # The RFC does not mention what to return when the device is not found, + # but to keep it consistent with the other errors, we return the error + # in json format with an "error" key and the value formatted in the same + # way. + return http.HttpResponseNotFound( + content='{"error": "device_not_found"}', + content_type="application/json", + ) + # Here we are returning the errors according to + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + # TODO: "slow_down" error (essentially rate-limiting). if device.status == device.AUTHORIZATION_PENDING: - pending_error = AuthorizationPendingError() - return http.HttpResponse( - content=pending_error.json, status=pending_error.status_code, content_type="application/json" + error = rfc8628_errors.AuthorizationPendingError() + elif device.status == device.DENIED: + error = rfc8628_errors.AccessDenied() + elif device.status == device.EXPIRED: + error = rfc8628_errors.ExpiredTokenError() + elif device.status != device.AUTHORIZED: + # It's technically impossible to get here because we've exhausted + # all the possible values for status. However, it does act as a + # reminder for developers when they add, in the future, new values + # (such as slow_down) that they must handle here. + return http.HttpResponseServerError( + content='{"error": "internal_error"}', + content_type="application/json", ) + else: + # AUTHORIZED is the only accepted state, anything else is + # rejected. + error = None - if device.status == device.DENIED: - access_denied_error = AccessDenied() + if error: return http.HttpResponse( - content=access_denied_error.json, - status=access_denied_error.status_code, + content=error.json, + status=error.status_code, content_type="application/json", ) url, headers, body, status = self.create_token_response(request) - if status != 200: return http.JsonResponse(data=json.loads(body), status=status) response = http.JsonResponse(data=json.loads(body), status=status) - for k, v in headers.items(): response[k] = v - device.status = device.EXPIRED - device.save(update_fields=["status"]) return response def post(self, request: http.HttpRequest, *args, **kwargs) -> http.HttpResponse: diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 075b9bc10..440809408 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -2,16 +2,13 @@ from django import forms, http from django.contrib.auth.decorators import login_required +from django.db.models import Q from django.shortcuts import render from django.urls import reverse 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 oauthlib.oauth2.rfc8628.errors import ( - AccessDenied, - ExpiredTokenError, -) from oauth2_provider.compat import login_not_required from oauth2_provider.models import Device, DeviceCodeResponse, DeviceRequest, create_device, get_device_model @@ -41,52 +38,71 @@ class DeviceForm(forms.Form): user_code = forms.CharField(required=True) -# it's common to see in real world products -# device flow's only asking the user to sign in after they input the -# user code but since the user has to be signed in regardless to approve the -# device login we're making the decision here to require being logged in -# up front @login_required def device_user_code_view(request): + """ + The view where the user is instructed (by the device) to come to in order to + enter the user code. More details in this section of the RFC: + https://datatracker.ietf.org/doc/html/rfc8628#section-3.3 + + Note: it's common to see in other implementations of this RFC that only ask the + user to sign in after they input the user code but since the user has to be signed + in regardless, to approve the device login we're making the decision here, for + simplicity, to require being logged in up front. + """ form = DeviceForm(request.POST) if request.method != "POST": return render(request, "oauth2_provider/device/user_code.html", {"form": form}) if not form.is_valid(): - return render(request, "oauth2_provider/device/user_code.html", {"form": form}) + form.add_error(None, "Form invalid") + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) user_code: str = form.cleaned_data["user_code"] - device: Device = get_device_model().objects.get(user_code=user_code) + try: + device: Device = get_device_model().objects.get(user_code=user_code) + except Device.DoesNotExist: + form.add_error("user_code", "Incorrect user code") + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=404) 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}) - if device.is_expired(): - device.status = device.EXPIRED - device.save(update_fields=["status"]) - raise ExpiredTokenError + form.add_error("user_code", "Expired user code") + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) # User of device has already made their decision for this device - if device.status in (device.DENIED, device.AUTHORIZED): - raise AccessDenied + if device.status != device.AUTHORIZATION_PENDING: + form.add_error("user_code", "User code has already been used") + return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) # 308 to indicate we want to keep the redirect being a POST request return http.HttpResponsePermanentRedirect( - reverse("oauth2_provider:device-confirm", kwargs={"device_code": device.device_code}), status=308 + reverse( + "oauth2_provider:device-confirm", + kwargs={"client_id": device.client_id, "user_code": user_code}, + ), + status=308, ) @login_required -def device_confirm_view(request: http.HttpRequest, device_code: str): - device: Device = get_device_model().objects.get(device_code=device_code) - - if device.status in (device.AUTHORIZED, device.DENIED): - return http.HttpResponse("Invalid") +def device_confirm_view(request: http.HttpRequest, client_id: str, user_code: str): + try: + device: Device = get_device_model().objects.get( + # there is a db index on client_id + Q(client_id=client_id) & Q(user_code=user_code) + ) + except Device.DoesNotExist: + return http.HttpResponseNotFound("

Device not found

") + + if device.status != device.AUTHORIZATION_PENDING: + # AUTHORIZATION_PENDING is the only accepted state, anything else implies + # that the user already approved/denied OR the deadline has passed (aka + # expired) + return http.HttpResponseBadRequest("Invalid") action = request.POST.get("action") diff --git a/tests/app/README.md b/tests/app/README.md index a2632b262..a0e279122 100644 --- a/tests/app/README.md +++ b/tests/app/README.md @@ -29,9 +29,9 @@ password: password You can update data in the IDP and then dump the data to a new seed file as follows. - ``` +``` python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json - ``` +``` ## /test/app/rp diff --git a/tests/test_device.py b/tests/test_device.py index 18f1fce39..9e2a8ecd5 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,8 +1,10 @@ +from datetime import datetime, timedelta, timezone from unittest import mock from urllib.parse import urlencode import django.http.response import pytest +from django.conf import settings from django.contrib.auth import get_user_model from django.test import RequestFactory from django.urls import reverse @@ -45,6 +47,10 @@ def setUpTestData(cls): client_secret="abcdefghijklmnopqrstuvwxyz1234567890", ) + def tearDown(self): + DeviceModel.objects.all().delete() + return super().tearDown() + class TestDeviceFlow(DeviceFlowBaseTestCase): """ @@ -129,7 +135,6 @@ 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] = { @@ -164,19 +169,37 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): assert "form" in get_response.context # Ensure the form is rendered in the context # 1.1.0 User visits the /device endpoint in their browsers and submits wrong user code - with pytest.raises(oauth2_provider.models.Device.DoesNotExist): + self.assertContains( + self.client.post(reverse("oauth2_provider:device"), data={"user_code": "invalid_code"}), + status_code=404, + text="Incorrect user code", + count=1, + ) + + # 1.1.1 User visits the /device endpoint in their browsers, or in the command line, submits + # a form that does not include the expected required field in the request. + self.assertContains( self.client.post( - reverse("oauth2_provider:device"), - data={"user_code": "invalid_code"}, - ) + reverse("oauth2_provider:device"), data={"not_user_code": "could_be_valid_code"} + ), + status_code=400, + text="Form invalid", + count=1, + ) + + # Note: the device not being in the expected test covered in the other test + # test_device_flow_authorization_device_invalid_state - # 1.1.1: user submits valid user code + # 1.1.2: user submits valid user code post_response_valid = self.client.post( reverse("oauth2_provider:device"), data={"user_code": "xyz"}, ) - device_confirm_url = reverse("oauth2_provider:device-confirm", kwargs={"device_code": "abc"}) + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "xyz", "client_id": self.application.client_id}, + ) assert post_response_valid.status_code == 308 # Ensure it redirects with 308 status assert post_response_valid["Location"] == device_confirm_url @@ -208,14 +231,19 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): data=urlencode(token_payload), content_type="application/x-www-form-urlencoded", ) - + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + token_response.__getitem__("content-type") == "application/json" assert token_response.status_code == 200 token_data = token_response.json() - - assert "access_token" in token_data - assert token_data["token_type"].lower() == "bearer" - assert "scope" in token_data + assert token_data == { + "access_token": mock.ANY, + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write", + "refresh_token": mock.ANY, + } # 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( @@ -228,6 +256,155 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): ) assert refresh_token.user == device.user + def test_device_flow_authorization_device_invalid_state_raises_error(self): + """ + This test asserts that only devices in the expected state (authorization-pending) + can be approved/denied by the user. + """ + + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + ) + device.save() + + # This simulates pytest.mark.parameterize, which unfortunately does not work with unittest + # and consequently with Django TestCase. + for invalid_state in ["authorized", "denied", "LOL_status"]: + # Set the device into an incorrect state. + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "user_code"}, + ), + status_code=400, + text="User code has already been used", + count=1, + ) + + def test_device_flow_authorization_device_expired_raises_error(self): + """ + This test asserts that only devices in the expected state (authorization-pending) + can be approved/denied by the user. + """ + + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=-1), # <- essentially expired + ) + device.save() + + self.assertContains( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "user_code"}, + ), + status_code=400, + text="Expired user code", + count=1, + ) + + def test_token_view_returns_error_if_device_in_invalid_state(self): + """ + This test asserts that the token view returns the appropriate errors as specified + in https://datatracker.ietf.org/doc/html/rfc8628#section-3.5, in case the device + has not yet been approved by the user. + """ + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + ) + device.save() + + token_payload = { + "device_code": "device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + testcases = [ + ("authorization-pending", '{"error": "authorization_pending"}', 400), + ("expired", '{"error": "expired_token"}', 400), + ("denied", '{"error": "access_denied"}', 400), + ("LOL_status", '{"error": "internal_error"}', 500), + ] + for invalid_state, expected_error_message, expected_error_code in testcases: + device.status = invalid_state + device.save(update_fields=["status"]) + + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + self.assertContains( + response=response, + status_code=expected_error_code, + text=expected_error_message, + count=1, + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + self.assertEqual(response.__getitem__("content-type"), "application/json") + + def test_token_view_returns_404_error_if_device_not_found(self): + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + ) + device.save() + + token_payload = { + "device_code": "another_device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + self.assertContains( + response=response, + status_code=404, + text="device_not_found", + count=1, + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + self.assertEqual(response.__getitem__("content-type"), "application/json") + @mock.patch( "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", lambda: "abc", @@ -285,3 +462,100 @@ def test_incorrect_client_id_sent(self): "error": "invalid_request", "error_description": "Invalid client_id parameter value.", } + + def test_device_confirm_and_user_code_views_require_login(self): + URLs = [ + reverse("oauth2_provider:device-confirm", kwargs={"user_code": None, "client_id": "abc"}), + reverse("oauth2_provider:device-confirm", kwargs={"user_code": "abc", "client_id": "abc"}), + reverse("oauth2_provider:device"), + ] + + for url in URLs: + r = self.client.get(url) + assert r.status_code == 302 + assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" + + r = self.client.post(url) + assert r.status_code == 302 + assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" + + def test_device_confirm_view_returns_404_when_device_does_not_exist(self): + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(), + ) + device.save() + + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "abc", "client_id": "abc"}, + ) + ), + status_code=404, + text="Device not found", + count=1, + ) + + def test_device_confirm_view_returns_400_when_device_in_incorrect_state(self): + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(), + ) + device.save() + + for invalid_state in ["authorized", "expired", "denied"]: + # Set the device into an incorrect state. + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + ), + status_code=400, + text="Invalid", + count=1, + ) + + def test_device_is_expired_method_sets_status_to_expired_if_deadline_passed(self): + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(tz=timezone.utc) + timedelta(seconds=-1), # <- essentially expired + ) + device.save() + + assert device.status == device.AUTHORIZATION_PENDING # default value + + # call is_expired() which should update the state + is_expired = device.is_expired() + + assert is_expired + assert device.status == device.EXPIRED From 895ab8c37437bf6bc65e59b917d9c1ae2979563f Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Fri, 6 Jun 2025 17:02:01 +0100 Subject: [PATCH 32/41] Use term DeviceGrant over Device Perferred to use the Grant suffix in order to keep the naming consistent with other grant models. --- ...lication_authorization_grant_type_device.py | 6 +++--- oauth2_provider/models.py | 18 +++++++++--------- oauth2_provider/settings.py | 4 ++-- oauth2_provider/utils.py | 4 ++-- oauth2_provider/views/base.py | 6 +++--- oauth2_provider/views/device.py | 18 ++++++++++++------ tests/test_device.py | 4 ++-- 7 files changed, 33 insertions(+), 27 deletions(-) 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 index 3996356d3..99769c398 100644 --- a/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py +++ b/oauth2_provider/migrations/0013_alter_application_authorization_grant_type_device.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): 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', + name='DeviceGrant', fields=[ ('id', models.BigAutoField(primary_key=True, serialize=False)), ('device_code', models.CharField(max_length=100, unique=True)), @@ -34,8 +34,8 @@ class Migration(migrations.Migration): ], options={ 'abstract': False, - 'swappable': 'OAUTH2_PROVIDER_DEVICE_MODEL', - 'constraints': [models.UniqueConstraint(fields=('device_code',), name='oauth2_provider_device_unique_device_code')], + 'swappable': 'OAUTH2_PROVIDER_DEVICE_GRANT_MODEL', + 'constraints': [models.UniqueConstraint(fields=('device_code',), name='oauth2_provider_devicegrant_unique_device_code')], }, ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index d5dce6cdc..e36f40295 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -655,7 +655,7 @@ class Meta(AbstractIDToken.Meta): swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" -class AbstractDevice(models.Model): +class AbstractDeviceGrant(models.Model): class Meta: abstract = True constraints = [ @@ -718,11 +718,11 @@ 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): +class DeviceGrant(AbstractDeviceGrant): objects = DeviceManager() - class Meta(AbstractDevice.Meta): - swappable = "OAUTH2_PROVIDER_DEVICE_MODEL" + class Meta(AbstractDeviceGrant.Meta): + swappable = "OAUTH2_PROVIDER_DEVICE_GRANT_MODEL" def natural_key(self): return (self.client_id, self.device_code, self.user_code) @@ -746,10 +746,10 @@ class DeviceCodeResponse: verification_uri_complete: Optional[Union[str, Callable]] = None -def create_device(device_request: DeviceRequest, device_response: DeviceCodeResponse) -> Device: +def create_device_grant(device_request: DeviceRequest, device_response: DeviceCodeResponse) -> DeviceGrant: now = datetime.now(tz=dt_timezone.utc) - return Device.objects.create( + return DeviceGrant.objects.create( client_id=device_request.client_id, device_code=device_response.device_code, user_code=device_response.user_code, @@ -763,9 +763,9 @@ def get_application_model(): 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_device_grant_model(): + """Return the DeviceGrant model that is active in this project.""" + return apps.get_model(oauth2_settings.DEVICE_GRANT_MODEL) def get_grant_model(): diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 45b2a5895..216f36ba8 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -30,7 +30,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") +DEVICE_GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_DEVICE_GRANT_MODEL", "oauth2_provider.DeviceGrant") 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") @@ -68,7 +68,7 @@ "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, "ID_TOKEN_MODEL": ID_TOKEN_MODEL, - "DEVICE_MODEL": DEVICE_MODEL, + "DEVICE_GRANT_MODEL": DEVICE_GRANT_MODEL, "DEVICE_FLOW_INTERVAL": 5, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py index 4d6138685..5c85007c1 100644 --- a/oauth2_provider/utils.py +++ b/oauth2_provider/utils.py @@ -94,7 +94,7 @@ def set_oauthlib_user_to_device_request_user(request: Request) -> None: """ # 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 + from oauth2_provider.models import DeviceGrant, get_device_grant_model - device: Device = get_device_model().objects.get(device_code=request._params["device_code"]) + device: DeviceGrant = get_device_grant_model().objects.get(device_code=request._params["device_code"]) request.user = device.user diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 358bfbf5c..676418977 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -15,7 +15,7 @@ from django.views.generic import FormView, View from oauthlib.oauth2.rfc8628 import errors as rfc8628_errors -from oauth2_provider.models import Device +from oauth2_provider.models import DeviceGrant from ..compat import login_not_required from ..exceptions import OAuthToolkitError @@ -318,8 +318,8 @@ def device_flow_token_response( self, request: http.HttpRequest, device_code: str, *args, **kwargs ) -> http.HttpResponse: try: - device = Device.objects.get(device_code=device_code) - except Device.DoesNotExist: + device = DeviceGrant.objects.get(device_code=device_code) + except DeviceGrant.DoesNotExist: # The RFC does not mention what to return when the device is not found, # but to keep it consistent with the other errors, we return the error # in json format with an "error" key and the value formatted in the same diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 440809408..5fa8febdd 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -11,7 +11,13 @@ from oauthlib.oauth2 import DeviceApplicationServer from oauth2_provider.compat import login_not_required -from oauth2_provider.models import Device, DeviceCodeResponse, DeviceRequest, create_device, get_device_model +from oauth2_provider.models import ( + DeviceCodeResponse, + DeviceGrant, + DeviceRequest, + create_device_grant, + get_device_grant_model, +) from oauth2_provider.views.mixins import OAuthLibMixin @@ -29,7 +35,7 @@ def post(self, request, *args, **kwargs): return http.JsonResponse(data=json.loads(response), status=status, headers=headers) device_response = DeviceCodeResponse(**response) - create_device(device_request, device_response) + create_device_grant(device_request, device_response) return http.JsonResponse(data=response, status=status, headers=headers) @@ -61,8 +67,8 @@ def device_user_code_view(request): user_code: str = form.cleaned_data["user_code"] try: - device: Device = get_device_model().objects.get(user_code=user_code) - except Device.DoesNotExist: + device: DeviceGrant = get_device_grant_model().objects.get(user_code=user_code) + except DeviceGrant.DoesNotExist: form.add_error("user_code", "Incorrect user code") return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=404) @@ -91,11 +97,11 @@ def device_user_code_view(request): @login_required def device_confirm_view(request: http.HttpRequest, client_id: str, user_code: str): try: - device: Device = get_device_model().objects.get( + device: DeviceGrant = get_device_grant_model().objects.get( # there is a db index on client_id Q(client_id=client_id) & Q(user_code=user_code) ) - except Device.DoesNotExist: + except DeviceGrant.DoesNotExist: return http.HttpResponseNotFound("

Device not found

") if device.status != device.AUTHORIZATION_PENDING: diff --git a/tests/test_device.py b/tests/test_device.py index 9e2a8ecd5..327e65cd2 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -13,7 +13,7 @@ from oauth2_provider.models import ( get_access_token_model, get_application_model, - get_device_model, + get_device_grant_model, get_refresh_token_model, ) from oauth2_provider.utils import set_oauthlib_user_to_device_request_user @@ -26,7 +26,7 @@ AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() -DeviceModel: oauth2_provider.models.Device = get_device_model() +DeviceModel: oauth2_provider.models.DeviceGrant = get_device_grant_model() @pytest.mark.usefixtures("oauth2_settings") From c5f0a8038462efbde584aaf2f4c56e7e326cf21b Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Wed, 11 Jun 2025 11:26:37 +0100 Subject: [PATCH 33/41] test: add missing unit test for user_code_generator --- oauth2_provider/utils.py | 2 ++ tests/test_utils.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py index 5c85007c1..a009d8a0e 100644 --- a/oauth2_provider/utils.py +++ b/oauth2_provider/utils.py @@ -65,6 +65,8 @@ def user_code_generator(user_code_length: int = 8) -> str: for our function we'll be using a base 32 character set """ + if user_code_length < 1: + raise ValueError("user_code_length needs to be greater than 0") # base32 character space character_space = "0123456789ABCDEFGHIJKLMNOPQRSTUV" diff --git a/tests/test_utils.py b/tests/test_utils.py index 2c319b6ea..eef4b985c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +import pytest + from oauth2_provider import utils @@ -25,3 +27,24 @@ def test_jwk_from_pem_caches_jwk(): jwk3 = utils.jwk_from_pem(a_different_tiny_rsa_key) assert jwk3 is not jwk1 + + +def test_user_code_generator(): + # Default argument, 8 characters + user_code = utils.user_code_generator() + assert isinstance(user_code, str) + assert len(user_code) == 8 + + for character in user_code: + assert character >= "0" + assert character <= "V" + + another_user_code = utils.user_code_generator() + assert another_user_code != user_code + + shorter_user_code = utils.user_code_generator(user_code_length=1) + assert len(shorter_user_code) == 1 + + with pytest.raises(ValueError): + utils.user_code_generator(user_code_length=0) + utils.user_code_generator(user_code_length=-1) From bdca7d77f87477b0846e8f3ebb3b04876b4d26c9 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Mon, 23 Jun 2025 17:11:13 +0100 Subject: [PATCH 34/41] Use latest oauthlib 3.3.0 --- pyproject.toml | 2 +- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0703333b0..a9ade3b7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ dependencies = [ "django >= 4.2", "requests >= 2.13.0", - "oauthlib @ git+https://github.com/oauthlib/oauthlib.git@master", + "oauthlib >= 3.3.0", "jwcrypto >= 1.5.0", ] diff --git a/tox.ini b/tox.ini index b15f8af23..29e93a2ae 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,7 @@ deps = dj52: Django>=5.2,<6.0 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework - git+https://github.com/oauthlib/oauthlib.git@master#egg=oauthlib + oauthlib>=3.3.0 jwcrypto coverage pytest @@ -79,7 +79,7 @@ commands = deps = Jinja2<3.1 sphinx<3 - git+https://github.com/oauthlib/oauthlib.git@master#egg=oauthlib + oauthlib>=3.3.0 m2r>=0.2.1 mistune<2 sphinx-rtd-theme From e89cee6ccaac8cb870ecef54438d43d5106da7c1 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Mon, 16 Jun 2025 10:39:26 +0100 Subject: [PATCH 35/41] Use class based views to make them more easily extensible This commit updates the views related to device auth flow from functions to class-based views. This makes it easy to import them in other projects and only overwrite small bits of functionality (specifically looking at templates and context_data). A 3rd view is added for the final step where the user is presented with the status of the device after they approve or deny. The views now also have a more "standard" django form behaviour, changes being relfected in the tests. --- .../device/device_grant_status.html | 11 + oauth2_provider/urls.py | 13 +- oauth2_provider/views/__init__.py | 2 +- oauth2_provider/views/device.py | 203 ++++++++++++------ tests/test_device.py | 169 ++++++++++----- 5 files changed, 277 insertions(+), 121 deletions(-) create mode 100644 oauth2_provider/templates/oauth2_provider/device/device_grant_status.html diff --git a/oauth2_provider/templates/oauth2_provider/device/device_grant_status.html b/oauth2_provider/templates/oauth2_provider/device/device_grant_status.html new file mode 100644 index 000000000..f2f0a6292 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/device/device_grant_status.html @@ -0,0 +1,11 @@ +{% extends "oauth2_provider/base.html" %} +{% block content %} + + + Device + + +

Device {{ object.get_status_display }}

+ + +{% endblock content %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 41b32a306..ea974e045 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -12,8 +12,17 @@ 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"), - path("device/", views.device_user_code_view, name="device"), - path("device-confirm//", views.device_confirm_view, name="device-confirm"), + path("device/", views.DeviceUserCodeView.as_view(), name="device"), + path( + "device-confirm//", + views.DeviceConfirmView.as_view(), + name="device-confirm", + ), + path( + "device-grant-status//", + views.DeviceGrantStatusView.as_view(), + name="device-grant-status", + ), ] diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 30ebb0bc7..24022f55e 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -17,4 +17,4 @@ from .introspect import IntrospectTokenView from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView -from .device import DeviceAuthorizationView, device_user_code_view, device_confirm_view +from .device import DeviceAuthorizationView, DeviceUserCodeView, DeviceConfirmView, DeviceGrantStatusView diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 5fa8febdd..64e4d85fb 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -1,13 +1,13 @@ import json from django import forms, http -from django.contrib.auth.decorators import login_required -from django.db.models import Q -from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ValidationError +from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.generic import View +from django.views.generic import DetailView, FormView, View from oauthlib.oauth2 import DeviceApplicationServer from oauth2_provider.compat import login_not_required @@ -40,12 +40,43 @@ def post(self, request, *args, **kwargs): return http.JsonResponse(data=response, status=status, headers=headers) -class DeviceForm(forms.Form): +class DeviceGrantForm(forms.Form): user_code = forms.CharField(required=True) + def clean_user_code(self): + """ + Performs validation on the user_code provided by the user and adds to the cleaned_data dict + the "device_grant" object associated with the user_code, which is useful to process the + response in the DeviceUserCodeView. -@login_required -def device_user_code_view(request): + It can raise one of the following ValidationErrors, with the associated codes: + + * incorrect_user_code: if a device grant associated with the user_code does not exist + * expired_user_code: if the device grant associated with the user_code has expired + * user_code_already_used: if the device grant associated with the user_code has been already + approved or denied. The only accepted state of the device grant is AUTHORIZATION_PENDING. + """ + cleaned_data = super().clean() + user_code: str = cleaned_data["user_code"] + try: + device_grant: DeviceGrant = get_device_grant_model().objects.get(user_code=user_code) + except DeviceGrant.DoesNotExist: + raise ValidationError("Incorrect user code", code="incorrect_user_code") + + if device_grant.is_expired(): + raise ValidationError("Expired user code", code="expired_user_code") + + # User of device has already made their decision for this device. + if device_grant.status != device_grant.AUTHORIZATION_PENDING: + raise ValidationError("User code has already been used", code="user_code_already_used") + + # Make the device_grant available to the View, saving one additional db call. + cleaned_data["device_grant"] = device_grant + + return user_code + + +class DeviceUserCodeView(LoginRequiredMixin, FormView): """ The view where the user is instructed (by the device) to come to in order to enter the user code. More details in this section of the RFC: @@ -56,69 +87,111 @@ def device_user_code_view(request): in regardless, to approve the device login we're making the decision here, for simplicity, to require being logged in up front. """ - form = DeviceForm(request.POST) - if request.method != "POST": - return render(request, "oauth2_provider/device/user_code.html", {"form": form}) + template_name = "oauth2_provider/device/user_code.html" + form_class = DeviceGrantForm - if not form.is_valid(): - form.add_error(None, "Form invalid") - return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) + def get_success_url(self): + return reverse( + "oauth2_provider:device-confirm", + kwargs={ + "client_id": self.device_grant.client_id, + "user_code": self.device_grant.user_code, + }, + ) - user_code: str = form.cleaned_data["user_code"] - try: - device: DeviceGrant = get_device_grant_model().objects.get(user_code=user_code) - except DeviceGrant.DoesNotExist: - form.add_error("user_code", "Incorrect user code") - return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=404) + def form_valid(self, form): + """ + Sets the device_grant on the instance so that it can be accessed + in get_success_url. It comes in handy when users want to overwrite + get_success_url, redirecting to the URL with the URL params pointing + to the current device. + """ + device_grant: DeviceGrant = form.cleaned_data["device_grant"] - device.user = request.user - device.save(update_fields=["user"]) + device_grant.user = self.request.user + device_grant.save(update_fields=["user"]) - if device.is_expired(): - form.add_error("user_code", "Expired user code") - return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) + self.device_grant = device_grant - # User of device has already made their decision for this device - if device.status != device.AUTHORIZATION_PENDING: - form.add_error("user_code", "User code has already been used") - return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400) + return super().form_valid(form) - # 308 to indicate we want to keep the redirect being a POST request - return http.HttpResponsePermanentRedirect( - reverse( - "oauth2_provider:device-confirm", - kwargs={"client_id": device.client_id, "user_code": user_code}, - ), - status=308, - ) - - -@login_required -def device_confirm_view(request: http.HttpRequest, client_id: str, user_code: str): - try: - device: DeviceGrant = get_device_grant_model().objects.get( - # there is a db index on client_id - Q(client_id=client_id) & Q(user_code=user_code) + +class DeviceConfirmForm(forms.Form): + """ + Simple form for the user to approve or deny the device. + """ + + action = forms.CharField(required=True) + + +class DeviceConfirmView(LoginRequiredMixin, FormView): + """ + The view where the user approves or denies a device. + """ + + template_name = "oauth2_provider/device/accept_deny.html" + form_class = DeviceConfirmForm + + def get_object(self): + """ + Returns the DeviceGrant object in the AUTHORIZATION_PENDING state identified + by the slugs client_id and user_code. Raises Http404 if not found. + """ + client_id, user_code = self.kwargs.get("client_id"), self.kwargs.get("user_code") + return get_object_or_404( + DeviceGrant, + client_id=client_id, + user_code=user_code, + status=DeviceGrant.AUTHORIZATION_PENDING, ) - except DeviceGrant.DoesNotExist: - return http.HttpResponseNotFound("

Device not found

") - - if device.status != device.AUTHORIZATION_PENDING: - # AUTHORIZATION_PENDING is the only accepted state, anything else implies - # that the user already approved/denied OR the deadline has passed (aka - # expired) - return http.HttpResponseBadRequest("Invalid") - - action = request.POST.get("action") - - if action == "accept": - device.status = device.AUTHORIZED - device.save(update_fields=["status"]) - return http.HttpResponse("approved") - elif action == "deny": - device.status = device.DENIED - device.save(update_fields=["status"]) - return http.HttpResponse("deny") - - return render(request, "oauth2_provider/device/accept_deny.html") + + def get_success_url(self): + return reverse( + "oauth2_provider:device-grant-status", + kwargs={ + "client_id": self.kwargs["client_id"], + "user_code": self.kwargs["user_code"], + }, + ) + + def get(self, request, *args, **kwargs): + """ + Enable GET requests for improved user experience. But validate that the URL params + are correct (i.e. there exists a device grant in the db that corresponds to the URL + params) by calling .get_object() + """ + _ = self.get_object() # raises 404 if URL parameters are incorrect + return super().get(request, args, kwargs) + + def form_valid(self, form): + """ + Uses get_object() to retrieves the DeviceGrant object and updates its state + to authorized or denied, based on the user input. + """ + device = self.get_object() + action = form.cleaned_data["action"] + + if action == "accept": + device.status = device.AUTHORIZED + device.save(update_fields=["status"]) + return super().form_valid(form) + elif action == "deny": + device.status = device.DENIED + device.save(update_fields=["status"]) + return super().form_valid(form) + else: + return http.HttpResponseBadRequest() + + +class DeviceGrantStatusView(LoginRequiredMixin, DetailView): + """ + The view to display the status of a DeviceGrant. + """ + + model = DeviceGrant + template_name = "oauth2_provider/device/device_grant_status.html" + + def get_object(self): + client_id, user_code = self.kwargs.get("client_id"), self.kwargs.get("user_code") + return get_object_or_404(DeviceGrant, client_id=client_id, user_code=user_code) diff --git a/tests/test_device.py b/tests/test_device.py index 327e65cd2..509f31fe6 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -4,6 +4,7 @@ import django.http.response import pytest +from django import http from django.conf import settings from django.contrib.auth import get_user_model from django.test import RequestFactory @@ -104,7 +105,7 @@ def test_device_flow_authorization_initiation(self): assert response.status_code == 200 # let's make sure the device was created in the db - assert DeviceModel.objects.get(device_code="abc") + assert DeviceModel.objects.get(device_code="abc").status == DeviceModel.AUTHORIZATION_PENDING assert response.json() == { "verification_uri": "example.com/device", @@ -120,18 +121,19 @@ def test_device_flow_authorization_initiation(self): ) def test_device_flow_authorization_user_code_confirm_and_access_token(self): """ - 1. User visits the /device endpoint in their browsers and submits the user code + This is a full user journey test. - the device and approve deny actions occur concurrently - (i.e the device is polling the token endpoint while the user - either approves or denies the device) + The device initiates the flow by calling the /device-authorization endpoint and starts + polling the /authorize endpoint getting back error until the user approves in the + browser. - -2(3)-. User approves or denies the device - -3(2)-. Device polls the /token endpoint + In the meantime, the user visits the /device endpoint in their browsers to submit the + user code and approve, after which the /authorize returns the tokens to the device. """ # ----------------------- - # 0: Setup device flow + # 0: Setup device flow, where the device sends an authorization request and + # starts polling. The polling will fail because the user has not approved yet # ----------------------- self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" @@ -142,12 +144,43 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): } request_as_x_www_form_urlencoded: str = urlencode(request_data) - django.http.response.JsonResponse = self.client.post( + device_authorization_response: 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 device_authorization_response.__getitem__("content-type") == "application/json" + device = DeviceModel.objects.get(device_code="abc") + self.assertJSONEqual( + raw=device_authorization_response.content, + expected_data={ + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": device.user_code, + "device_code": device.device_code, + "interval": 5, + }, + ) + + # Device polls /token and gets back error because the user hasn't approved yet + token_payload = { + "device_code": device.device_code, + "client_id": self.application.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + token_response: http.response.JsonResponse = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + assert token_response.__getitem__("content-type") == "application/json" + assert token_response.status_code == 400 + self.assertJSONEqual(raw=token_response.content, expected_data={"error": "authorization_pending"}) + # /device and /device_confirm require a user to be logged in # to access it UserModel.objects.create_user( @@ -171,54 +204,55 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): # 1.1.0 User visits the /device endpoint in their browsers and submits wrong user code self.assertContains( self.client.post(reverse("oauth2_provider:device"), data={"user_code": "invalid_code"}), - status_code=404, + status_code=200, text="Incorrect user code", count=1, ) - # 1.1.1 User visits the /device endpoint in their browsers, or in the command line, submits - # a form that does not include the expected required field in the request. - self.assertContains( - self.client.post( - reverse("oauth2_provider:device"), data={"not_user_code": "could_be_valid_code"} - ), - status_code=400, - text="Form invalid", - count=1, - ) - - # Note: the device not being in the expected test covered in the other test - # test_device_flow_authorization_device_invalid_state - - # 1.1.2: user submits valid user code - post_response_valid = self.client.post( - reverse("oauth2_provider:device"), - data={"user_code": "xyz"}, - ) + # Note: the device not being in the expected test covered in the other tests + # 1.1.1: user submits valid user code device_confirm_url = reverse( "oauth2_provider:device-confirm", kwargs={"user_code": "xyz", "client_id": self.application.client_id}, ) - assert post_response_valid.status_code == 308 # Ensure it redirects with 308 status - assert post_response_valid["Location"] == device_confirm_url + + self.assertRedirects( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "xyz"}, + ), + expected_url=device_confirm_url, + ) # -------------------------------------------------------------------------------- # 2: We redirect to the accept/deny form (the user is still in their browser) # and approves # -------------------------------------------------------------------------------- - get_confirm = self.client.get(device_confirm_url) - assert get_confirm.status_code == 200 + device_grant_status_url = reverse( + "oauth2_provider:device-grant-status", + kwargs={"user_code": "xyz", "client_id": self.application.client_id}, + ) + + self.assertRedirects( + response=self.client.post(device_confirm_url, data={"action": "accept"}), + expected_url=device_grant_status_url, + ) - approve_response = self.client.post(device_confirm_url, data={"action": "accept"}) - assert approve_response.status_code == 200 - assert approve_response.content.decode() == "approved" + # -------------------------------------------------------------------------------- + # 3: We redirect to the device grant status page (the user is still in their browser) + # -------------------------------------------------------------------------------- + self.assertContains( + response=self.client.get(device_grant_status_url), + text="Device Authorized", + count=1, + ) device = DeviceModel.objects.get(device_code="abc") assert device.status == device.AUTHORIZED # ------------------------- - # 3: Device polls /token + # 4: Device polls /token successfully # ------------------------- token_payload = { "device_code": device.device_code, @@ -233,7 +267,7 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): ) # TokenView should always respond with application/json as it's meant to be # consumed by devices. - token_response.__getitem__("content-type") == "application/json" + assert token_response.__getitem__("content-type") == "application/json" assert token_response.status_code == 200 token_data = token_response.json() @@ -256,7 +290,7 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): ) assert refresh_token.user == device.user - def test_device_flow_authorization_device_invalid_state_raises_error(self): + def test_device_flow_authorization_device_invalid_state_returns_form_error(self): """ This test asserts that only devices in the expected state (authorization-pending) can be approved/denied by the user. @@ -290,12 +324,12 @@ def test_device_flow_authorization_device_invalid_state_raises_error(self): reverse("oauth2_provider:device"), data={"user_code": "user_code"}, ), - status_code=400, + status_code=200, text="User code has already been used", count=1, ) - def test_device_flow_authorization_device_expired_raises_error(self): + def test_device_flow_authorization_device_expired_returns_form_error(self): """ This test asserts that only devices in the expected state (authorization-pending) can be approved/denied by the user. @@ -322,7 +356,7 @@ def test_device_flow_authorization_device_expired_raises_error(self): reverse("oauth2_provider:device"), data={"user_code": "user_code"}, ), - status_code=400, + status_code=200, text="Expired user code", count=1, ) @@ -479,7 +513,7 @@ def test_device_confirm_and_user_code_views_require_login(self): assert r.status_code == 302 assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" - def test_device_confirm_view_returns_404_when_device_does_not_exist(self): + def test_device_confirm_view_GET_returns_404_when_device_does_not_exist(self): UserModel.objects.create_user( username="test_user_device_flow", email="test_device@example.com", @@ -497,18 +531,33 @@ def test_device_confirm_view_returns_404_when_device_does_not_exist(self): device.save() self.assertContains( - response=self.client.post( + response=self.client.get( reverse( "oauth2_provider:device-confirm", - kwargs={"user_code": "abc", "client_id": "abc"}, + kwargs={"user_code": "not_user_code", "client_id": "not_client_id"}, ) ), status_code=404, - text="Device not found", - count=1, + text="The requested resource was not found on this server.", ) - def test_device_confirm_view_returns_400_when_device_in_incorrect_state(self): + # Asserts for valid user_code and client_id but invalid states + for invalid_state in ["authorized", "denied", "expired"]: + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.get( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "client_id"}, + ) + ), + status_code=404, + text="The requested resource was not found on this server.", + ) + + def test_device_confirm_view_POST_returns_404_when_device_does_not_exist(self): UserModel.objects.create_user( username="test_user_device_flow", email="test_device@example.com", @@ -525,8 +574,21 @@ def test_device_confirm_view_returns_400_when_device_in_incorrect_state(self): ) device.save() - for invalid_state in ["authorized", "expired", "denied"]: - # Set the device into an incorrect state. + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "client_id"}, + ), + data={"action": "accept"}, + ), + status_code=404, + text="The requested resource was not found on this server.", + count=1, + ) + + # Asserts for valid user_code and client_id but invalid states + for invalid_state in ["authorized", "denied", "expired"]: device.status = invalid_state device.save(update_fields=["status"]) @@ -535,10 +597,11 @@ def test_device_confirm_view_returns_400_when_device_in_incorrect_state(self): reverse( "oauth2_provider:device-confirm", kwargs={"user_code": "user_code", "client_id": "client_id"}, - ) + ), + data={"action": "accept"}, ), - status_code=400, - text="Invalid", + status_code=404, + text="The requested resource was not found on this server.", count=1, ) From 039b99ed0985776e16fca367e0a118a7b0d41ae0 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Wed, 13 Aug 2025 17:31:53 +0100 Subject: [PATCH 36/41] Bugfix: handle missing client_id This commit fixes a bug where the application crashes if the client_id parameter is not sent in the body, by checking the status returned by the oauthlib function before any further processing. Test for this usecase is added. --- oauth2_provider/views/device.py | 3 +-- tests/test_device.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/views/device.py b/oauth2_provider/views/device.py index 64e4d85fb..f3dccf2ba 100644 --- a/oauth2_provider/views/device.py +++ b/oauth2_provider/views/device.py @@ -29,11 +29,10 @@ class DeviceAuthorizationView(OAuthLibMixin, View): 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_request = DeviceRequest(client_id=request.POST["client_id"], scope=request.POST.get("scope")) device_response = DeviceCodeResponse(**response) create_device_grant(device_request, device_response) diff --git a/tests/test_device.py b/tests/test_device.py index 509f31fe6..926cc664b 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -497,6 +497,28 @@ def test_incorrect_client_id_sent(self): "error_description": "Invalid client_id parameter value.", } + def test_missing_client_id(self): + """ + Ensure the correct error is returned when the client id is missing. + """ + request_data: dict[str, str] = { + "not_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": "Missing client_id parameter.", + } + def test_device_confirm_and_user_code_views_require_login(self): URLs = [ reverse("oauth2_provider:device-confirm", kwargs={"user_code": None, "client_id": "abc"}), From 4dea003df9407fc4bc1f7eefa2be495df00b2b5a Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Thu, 14 Aug 2025 12:35:40 +0100 Subject: [PATCH 37/41] Increase test coverage This commit adds two tests that cover lines flagged by codecov. One test covers the use case when oauthlib create_token_response method returns a different status than 200. The second test covers an edge case where the second time is_expired is called, the memoised value is returned. --- oauth2_provider/views/base.py | 5 ++-- tests/test_device.py | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 676418977..43c8e3213 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -360,10 +360,11 @@ def device_flow_token_response( ) url, headers, body, status = self.create_token_response(request) + response = http.JsonResponse(data=json.loads(body), status=status) + if status != 200: - return http.JsonResponse(data=json.loads(body), status=status) + return response - response = http.JsonResponse(data=json.loads(body), status=status) for k, v in headers.items(): response[k] = v diff --git a/tests/test_device.py b/tests/test_device.py index 926cc664b..8c2d75977 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -439,6 +439,50 @@ def test_token_view_returns_404_error_if_device_not_found(self): # consumed by devices. self.assertEqual(response.__getitem__("content-type"), "application/json") + def test_token_view_status_equals_what_oauthlib_token_response_method_returns(self): + """ + Tests the use case where oauthlib create_token_response returns a status different + than 200. + """ + + class MockOauthlibCoreClass: + def create_token_response(self, _): + return "url", {"headers_are_ignored": True}, '{"Key": "Value"}', 299 + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + status="authorized", + ) + device.save() + + token_payload = { + "device_code": "device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + with mock.patch( + "oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core", MockOauthlibCoreClass + ): + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + + self.assertEqual(response["content-type"], "application/json") + self.assertContains( + response=response, + status_code=299, + text='{"Key": "Value"}', + count=1, + ) + assert not response.has_header("headers_are_ignored") + @mock.patch( "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", lambda: "abc", @@ -644,3 +688,9 @@ def test_device_is_expired_method_sets_status_to_expired_if_deadline_passed(self assert is_expired assert device.status == device.EXPIRED + + # calling again is_expired() should return true and not change the state + is_expired = device.is_expired() + + assert is_expired + assert device.status == device.EXPIRED From d77193f0e5c97341dcf737f3036e0feb4ed62126 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Mon, 18 Aug 2025 11:48:30 +0300 Subject: [PATCH 38/41] Add missing test case in the validor class This commit adds a missing test that covers the newly introduced use case where the client application is Public and the grant type is device code. Here, there is no need for a HTTP auth therefore always expected a True return value. --- tests/test_oauth2_validators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 14c74506e..7e7e46de7 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -180,6 +180,12 @@ def test_authenticate_basic_auth_not_utf8(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic test"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_public_app_with_device_code(self): + self.request.grant_type = "urn:ietf:params:oauth:grant-type:device_code" + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) + self.application.client_type = Application.CLIENT_PUBLIC + self.assertTrue(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_check_secret(self): hashed = make_password(CLEARTEXT_SECRET) self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, CLEARTEXT_SECRET)) From c10a471f20a8b940849e7c8de0269e70412f84c0 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Mon, 18 Aug 2025 11:56:48 +0300 Subject: [PATCH 39/41] Remove serialisation methods of DeviceGrant class It seems useless to think of serialisation of DeviceGrant objects, since they are by design ephemeral. The equivalent "grant" classes for other grant_types, such as "class Grant(AbstractGrant)" does not have these methods defined either. Therefore, best to remove them. --- oauth2_provider/models.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index e36f40295..523ade289 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -713,20 +713,10 @@ def is_expired(self): return False -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 DeviceGrant(AbstractDeviceGrant): - objects = DeviceManager() - class Meta(AbstractDeviceGrant.Meta): swappable = "OAUTH2_PROVIDER_DEVICE_GRANT_MODEL" - def natural_key(self): - return (self.client_id, self.device_code, self.user_code) - @dataclass class DeviceRequest: From 9f693ebb944068e7487f983c8bd6ad58e42e6315 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Fri, 7 Nov 2025 14:41:49 +0000 Subject: [PATCH 40/41] Add tests that cover DeviceConfirmView for various values for action This commit adds test covereage for the two if branches in DeviceConfirmView.form_valid. --- tests/test_device.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_device.py b/tests/test_device.py index 8c2d75977..727c81002 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -290,6 +290,79 @@ def test_device_flow_authorization_user_code_confirm_and_access_token(self): ) assert refresh_token.user == device.user + def test_user_denies_access(self): + """ + This test asserts the when the user denies access, the state of the grant is saved + and the user is redirected to the page where they can see the "denied" state. + + The /token View returning the appropriate message for the "denied" state is covered + in test_token_view_returns_error_if_device_in_invalid_state. + """ + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + status=DeviceModel.AUTHORIZATION_PENDING, + ) + device.save() + + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + + device_grant_status_url = reverse( + "oauth2_provider:device-grant-status", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + + self.assertRedirects( + response=self.client.post(device_confirm_url, data={"action": "deny"}), + expected_url=device_grant_status_url, + ) + + device.refresh_from_db() + assert device.status == device.DENIED + + def test_device_confirm_view_returns_400_on_incorrect_action(self): + """ + This test asserts that the confirm view returns 400 if action is not + "accept" or "deny". + """ + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + status=DeviceModel.AUTHORIZATION_PENDING, + ) + device.save() + + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + response = self.client.post(device_confirm_url, data={"action": "inccorect_action"}) + + assert response.status_code == 400 + def test_device_flow_authorization_device_invalid_state_returns_form_error(self): """ This test asserts that only devices in the expected state (authorization-pending) From b66de55d80b539c06e7c78a1eb4876df7c138ee7 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana Date: Fri, 7 Nov 2025 17:20:27 +0000 Subject: [PATCH 41/41] Add instructions in the test IDP for device authorization flow This commit updates the test IDP fixture seed file with a new Device Flow OAuth application and updates the Readme with the corresponding instructions. The device flow does not really have a RP application, since the device itself is a RP, therefore used curl commands as an equivalent for PR. --- tests/app/README.md | 32 ++++++++++++++++++++++++++++++++ tests/app/idp/fixtures/seed.json | 21 +++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/app/README.md b/tests/app/README.md index a0e279122..1d13b2414 100644 --- a/tests/app/README.md +++ b/tests/app/README.md @@ -4,6 +4,8 @@ These apps are for local end to end testing of DOT features. They were implement local test environments. You should be able to start both and instance of the IDP and RP using the directions below, then test the functionality of the IDP using the RP. +The IDP seed data includes a Device Authorization OAuth application as well. + ## /tests/app/idp This is an example IDP implementation for end to end testing. There are pre-configured fixtures which will work with the sample RP. @@ -33,6 +35,36 @@ password: password python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json ``` +### Device Authorization example + +For testing out the device authorization flow, we don't really need a RP, as the device itself +is the "relying party". The seed data includes a Device Authorization Application, meaning +you could directly start the device authorization flow using `curl`. In the real world, the device +would be sending these request that we send here with `curl`. + +_Note:_ you can find these `curl` commands in the Tutorial section of the documentation as well. + +```sh +# Initiate device authorization flow on the device; here we use the client_id +# of the Device Authorization App from the seed data. +curl --location 'http://127.0.0.1:8000/o/device-authorization/' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=Qg8AaxKLs1c2W3PR70Sv5QxuSEREicKUlf83iGX3' +``` + +Follow the `verification_uri` from the response (should be similar to http://127.0.0.1:8000/o/device"), +enter the user code, approve, and then send another `curl` command to get the token. + +```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=Qg8AaxKLs1c2W3PR70Sv5QxuSEREicKUlf83iGX3' \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code' +``` + +The response should include the access token. + ## /test/app/rp This is an example RP. It is a SPA built with Svelte. diff --git a/tests/app/idp/fixtures/seed.json b/tests/app/idp/fixtures/seed.json index b77d1f4e2..382102373 100644 --- a/tests/app/idp/fixtures/seed.json +++ b/tests/app/idp/fixtures/seed.json @@ -34,5 +34,26 @@ "algorithm": "RS256", "allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173" } +}, +{ + "model": "oauth2_provider.application", + "fields": { + "client_id": "Qg8AaxKLs1c2W3PR70Sv5QxuSEREicKUlf83iGX3", + "user": [ + "superuser" + ], + "redirect_uris": "", + "post_logout_redirect_uris": "", + "client_type": "public", + "authorization_grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_secret": "pbkdf2_sha256$870000$x1A7AKB9YMmNX7v2otXt1C$Yxucj9o/QlF16AxqN5LXo+Se0Sy3FO5x4Q35Lw1FGqM=", + "hash_client_secret": true, + "name": "Device Authorization App", + "skip_authorization": false, + "created": "2025-11-07T16:56:23.156Z", + "updated": "2025-11-07T16:56:23.156Z", + "algorithm": "", + "allowed_origins": "" + } } ]