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
+
+
+
+{% 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
+
+
+
+{% 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:
+
+
+
+{% 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:
+
+
+
+{% 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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