Skip to content

Commit 87fef47

Browse files
authored
feat: Add device authorization grant (device code flow - rfc 8628) (#1539)
* 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) * 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 * 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 * Update the grant type mapping to recognize device code * 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 * 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 * Create device authorization view This view is to be used in an authorization server in order to provide a /device endpoint * Add the device user code form * Add approve deny form * Update token endpoint * Use latest oauthlib 3.3.0
1 parent 01dfd06 commit 87fef47

37 files changed

+2362
-51
lines changed

AUTHORS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ Bas van Oostveen
3636
Brian Helba
3737
Carl Schwan
3838
Cihad GUNDOGDU
39+
Cristian Prigoana
3940
Daniel Golding
4041
Daniel 'Vector' Kerr
4142
Darrel O'Pry
4243
Dave Burkholder
4344
David Fischer
4445
David Hill
4546
David Smith
47+
David Uzumaki
4648
Dawid Wolski
4749
Diego Garcia
4850
Dominik George

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
* #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
2828
deployments for development previews and user acceptance testing.
2929
* #1586 Turkish language support added
30+
* #1539 Add device authorization grant support
3031

3132
### Changed
3233
The project is now hosted in the django-oauth organization.
55.8 KB
Loading
16.5 KB
Loading
18.1 KB
Loading

docs/tutorial/tutorial.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ Tutorials
99
tutorial_03
1010
tutorial_04
1111
tutorial_05
12-
12+
tutorial_06

docs/tutorial/tutorial_06.rst

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
Part 6 - Device authorization grant flow
2+
====================================================
3+
4+
Scenario
5+
--------
6+
In :doc:`Part 1 <tutorial_01>` you created your own :term:`Authorization Server` and it's running along just fine.
7+
You have devices that your users have, and those users need to authenticate the device against your
8+
:term:`Authorization Server` in order to make the required API calls.
9+
10+
Device Authorization
11+
--------------------
12+
The OAuth 2.0 device authorization grant is designed for Internet
13+
connected devices that either lack a browser to perform a user-agent
14+
based authorization or are input-constrained to the extent that
15+
requiring the user to input text in order to authenticate during the
16+
authorization flow is impractical. It enables OAuth clients on such
17+
devices (like smart TVs, media consoles, digital picture frames, and
18+
printers) to obtain user authorization to access protected resources
19+
by using a user agent on a separate device.
20+
21+
Point your browser to `http://127.0.0.1:8000/o/applications/register/` to create an application.
22+
23+
Fill the form as shown in the screenshot below, and before saving, take note of the ``Client id``.
24+
Make sure the client type is set to "Public." There are cases where a confidential client makes sense,
25+
but generally, it is assumed the device is unable to safely store the client secret.
26+
27+
.. image:: ../_images/application-register-device-code.png
28+
:alt: Device Authorization application registration
29+
30+
Ensure the setting ``OAUTH_DEVICE_VERIFICATION_URI`` is set to a URI you want to return in the
31+
`verification_uri` key in the response. This is what the device will display to the user.
32+
33+
1. Navigate to the tests/app/idp directory:
34+
35+
.. code-block:: sh
36+
37+
cd tests/app/idp
38+
39+
then start the server
40+
41+
.. code-block:: sh
42+
43+
python manage.py runserver
44+
45+
.. _RFC: https://www.rfc-editor.org/rfc/rfc8628
46+
.. _RFC section 3.5: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
47+
48+
2. To initiate device authorization, send this request (in the real world, the device
49+
makes this request). In `RFC`_ Figure 1, this is step (A).
50+
51+
.. code-block:: sh
52+
53+
curl --location 'http://127.0.0.1:8000/o/device-authorization/' \
54+
--header 'Content-Type: application/x-www-form-urlencoded' \
55+
--data-urlencode 'client_id={your application client id}'
56+
57+
The OAuth2 provider will return the following response. In `RFC`_ Figure 1, this is step (B).
58+
59+
.. code-block:: json
60+
61+
{
62+
"verification_uri": "http://127.0.0.1:8000/o/device",
63+
"expires_in": 1800,
64+
"user_code": "A32RVADM",
65+
"device_code": "G30j94v0kNfipD4KmGLTWeL4eZnKHm",
66+
"interval": 5
67+
}
68+
69+
In the real world, the device will somehow make the value of the `user_code` available to the user (either on-screen display,
70+
or Bluetooth, NFC, etc.). In `RFC`_ Figure 1, this is step (C).
71+
72+
3. Go to `http://127.0.0.1:8000/o/device` in your browser.
73+
74+
.. image:: ../_images/device-enter-code-displayed.png
75+
76+
Enter the code, and it will redirect you to the device-confirm endpoint. In `RFC`_ Figure 1, this is step (D).
77+
78+
Device-confirm endpoint
79+
-----------------------
80+
4. Device polling occurs concurrently while the user approves or denies the request.
81+
82+
.. image:: ../_images/device-approve-deny.png
83+
84+
Device polling
85+
--------------
86+
Send the following request (in the real world, the device makes this request). In `RFC`_ Figure 1, this is step (E).
87+
88+
.. code-block:: sh
89+
90+
curl --location 'http://localhost:8000/o/token/' \
91+
--header 'Content-Type: application/x-www-form-urlencoded' \
92+
--data-urlencode 'device_code={the device code from the device-authorization response}' \
93+
--data-urlencode 'client_id={your application client id}' \
94+
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code'
95+
96+
In `RFC`_ Figure 1, there are multiple options for step (F), as per `RFC section 3.5`_. Until the user enters the code
97+
in the browser and approves, the response will be 400:
98+
99+
.. code-block:: json
100+
101+
{"error": "authorization_pending"}
102+
103+
Or if the user has denied the device, the response is 400:
104+
105+
.. code-block:: json
106+
107+
{"error": "access_denied"}
108+
109+
Or if the token has expired, the response is 400:
110+
111+
.. code-block:: json
112+
113+
{"error": "expired_token"}
114+
115+
116+
However, after the user approves, the response will be 200:
117+
118+
.. code-block:: json
119+
120+
{
121+
"access_token": "SkJMgyL432P04nHDPyB63DEAM0nVxk",
122+
"expires_in": 36000,
123+
"token_type": "Bearer",
124+
"scope": "openid",
125+
"refresh_token": "Go6VumurDfFAeCeKrpCKPDtElV77id"
126+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Generated by Django 5.1.5 on 2025-01-24 14:00
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('oauth2_provider', '0012_add_token_checksum'),
12+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name='application',
18+
name='authorization_grant_type',
19+
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),
20+
),
21+
migrations.CreateModel(
22+
name='DeviceGrant',
23+
fields=[
24+
('id', models.BigAutoField(primary_key=True, serialize=False)),
25+
('device_code', models.CharField(max_length=100, unique=True)),
26+
('user_code', models.CharField(max_length=100)),
27+
('scope', models.CharField(max_length=64, null=True)),
28+
('interval', models.IntegerField(default=5)),
29+
('expires', models.DateTimeField()),
30+
('status', models.CharField(blank=True, choices=[('authorized', 'Authorized'), ('authorization-pending', 'Authorization pending'), ('expired', 'Expired'), ('denied', 'Denied')], default='authorization-pending', max_length=64)),
31+
('client_id', models.CharField(db_index=True, max_length=100)),
32+
('last_checked', models.DateTimeField(auto_now=True)),
33+
('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)),
34+
],
35+
options={
36+
'abstract': False,
37+
'swappable': 'OAUTH2_PROVIDER_DEVICE_GRANT_MODEL',
38+
'constraints': [models.UniqueConstraint(fields=('device_code',), name='oauth2_provider_devicegrant_unique_device_code')],
39+
},
40+
),
41+
]

oauth2_provider/models.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import time
44
import uuid
55
from contextlib import suppress
6-
from datetime import timedelta
6+
from dataclasses import dataclass
7+
from datetime import datetime, timedelta
8+
from datetime import timezone as dt_timezone
9+
from typing import Callable, Optional, Union
710
from urllib.parse import parse_qsl, urlparse
811

912
from django.apps import apps
@@ -86,12 +89,14 @@ class AbstractApplication(models.Model):
8689
)
8790

8891
GRANT_AUTHORIZATION_CODE = "authorization-code"
92+
GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
8993
GRANT_IMPLICIT = "implicit"
9094
GRANT_PASSWORD = "password"
9195
GRANT_CLIENT_CREDENTIALS = "client-credentials"
9296
GRANT_OPENID_HYBRID = "openid-hybrid"
9397
GRANT_TYPES = (
9498
(GRANT_AUTHORIZATION_CODE, _("Authorization code")),
99+
(GRANT_DEVICE_CODE, _("Device Code")),
95100
(GRANT_IMPLICIT, _("Implicit")),
96101
(GRANT_PASSWORD, _("Resource owner password-based")),
97102
(GRANT_CLIENT_CREDENTIALS, _("Client credentials")),
@@ -127,7 +132,7 @@ class AbstractApplication(models.Model):
127132
default="",
128133
)
129134
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
130-
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
135+
authorization_grant_type = models.CharField(max_length=44, choices=GRANT_TYPES)
131136
client_secret = ClientSecretField(
132137
max_length=255,
133138
blank=True,
@@ -650,11 +655,109 @@ class Meta(AbstractIDToken.Meta):
650655
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL"
651656

652657

658+
class AbstractDeviceGrant(models.Model):
659+
class Meta:
660+
abstract = True
661+
constraints = [
662+
models.UniqueConstraint(
663+
fields=["device_code"],
664+
name="%(app_label)s_%(class)s_unique_device_code",
665+
),
666+
]
667+
668+
AUTHORIZED = "authorized"
669+
AUTHORIZATION_PENDING = "authorization-pending"
670+
EXPIRED = "expired"
671+
DENIED = "denied"
672+
673+
DEVICE_FLOW_STATUS = (
674+
(AUTHORIZED, _("Authorized")),
675+
(AUTHORIZATION_PENDING, _("Authorization pending")),
676+
(EXPIRED, _("Expired")),
677+
(DENIED, _("Denied")),
678+
)
679+
680+
id = models.BigAutoField(primary_key=True)
681+
user = models.ForeignKey(
682+
settings.AUTH_USER_MODEL,
683+
related_name="%(app_label)s_%(class)s",
684+
null=True,
685+
blank=True,
686+
on_delete=models.CASCADE,
687+
)
688+
device_code = models.CharField(max_length=100, unique=True)
689+
user_code = models.CharField(max_length=100)
690+
scope = models.CharField(max_length=64, null=True)
691+
interval = models.IntegerField(default=5)
692+
expires = models.DateTimeField()
693+
status = models.CharField(
694+
max_length=64, blank=True, choices=DEVICE_FLOW_STATUS, default=AUTHORIZATION_PENDING
695+
)
696+
client_id = models.CharField(max_length=100, db_index=True)
697+
last_checked = models.DateTimeField(auto_now=True)
698+
699+
def is_expired(self):
700+
"""
701+
Check device flow session expiration and set the status to "expired" if current time
702+
is past the "expires" deadline.
703+
"""
704+
if self.status == self.EXPIRED:
705+
return True
706+
707+
now = datetime.now(tz=dt_timezone.utc)
708+
if now >= self.expires:
709+
self.status = self.EXPIRED
710+
self.save(update_fields=["status"])
711+
return True
712+
713+
return False
714+
715+
716+
class DeviceGrant(AbstractDeviceGrant):
717+
class Meta(AbstractDeviceGrant.Meta):
718+
swappable = "OAUTH2_PROVIDER_DEVICE_GRANT_MODEL"
719+
720+
721+
@dataclass
722+
class DeviceRequest:
723+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
724+
# scope is optional
725+
client_id: str
726+
scope: Optional[str] = None
727+
728+
729+
@dataclass
730+
class DeviceCodeResponse:
731+
verification_uri: str
732+
expires_in: int
733+
user_code: int
734+
device_code: str
735+
interval: int
736+
verification_uri_complete: Optional[Union[str, Callable]] = None
737+
738+
739+
def create_device_grant(device_request: DeviceRequest, device_response: DeviceCodeResponse) -> DeviceGrant:
740+
now = datetime.now(tz=dt_timezone.utc)
741+
742+
return DeviceGrant.objects.create(
743+
client_id=device_request.client_id,
744+
device_code=device_response.device_code,
745+
user_code=device_response.user_code,
746+
scope=device_request.scope,
747+
expires=now + timedelta(seconds=device_response.expires_in),
748+
)
749+
750+
653751
def get_application_model():
654752
"""Return the Application model that is active in this project."""
655753
return apps.get_model(oauth2_settings.APPLICATION_MODEL)
656754

657755

756+
def get_device_grant_model():
757+
"""Return the DeviceGrant model that is active in this project."""
758+
return apps.get_model(oauth2_settings.DEVICE_GRANT_MODEL)
759+
760+
658761
def get_grant_model():
659762
"""Return the Grant model that is active in this project."""
660763
return apps.get_model(oauth2_settings.GRANT_MODEL)

oauth2_provider/oauth2_backends.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from urllib.parse import urlparse, urlunparse
33

4+
from django.http import HttpRequest
45
from oauthlib import oauth2
56
from oauthlib.common import Request as OauthlibRequest
67
from oauthlib.common import quote, urlencode, urlencoded
@@ -75,6 +76,8 @@ def extract_headers(self, request):
7576
del headers["wsgi.errors"]
7677
if "HTTP_AUTHORIZATION" in headers:
7778
headers["Authorization"] = headers["HTTP_AUTHORIZATION"]
79+
if "CONTENT_TYPE" in headers:
80+
headers["Content-Type"] = headers["CONTENT_TYPE"]
7881
# Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant,
7982
# if the origin is allowed by RequestValidator.is_origin_allowed.
8083
# https://github.com/oauthlib/oauthlib/pull/791
@@ -148,6 +151,16 @@ def create_authorization_response(self, request, scopes, credentials, allow):
148151
except oauth2.OAuth2Error as error:
149152
raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"])
150153

154+
def create_device_authorization_response(self, request: HttpRequest):
155+
uri, http_method, body, headers = self._extract_params(request)
156+
try:
157+
headers, body, status = self.server.create_device_authorization_response(
158+
uri, http_method, body, headers
159+
)
160+
return headers, body, status
161+
except OAuth2Error as exc:
162+
return exc.headers, exc.json, exc.status_code
163+
151164
def create_token_response(self, request):
152165
"""
153166
A wrapper method that calls create_token_response on `server_class` instance.

0 commit comments

Comments
 (0)