Skip to content

Commit 332f851

Browse files
committed
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
1 parent 6dda42b commit 332f851

File tree

7 files changed

+383
-62
lines changed

7 files changed

+383
-62
lines changed

docs/tutorial/tutorial_06.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ then start the server
4343
python manage.py runserver
4444
4545
.. _RFC: https://www.rfc-editor.org/rfc/rfc8628
46+
.. _RFC3.5: https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
4647

4748
2. To initiate device authorization, send this request (in the real world, the device
4849
makes this request). In `RFC`_ Figure 1, this is step (A).
@@ -92,14 +93,16 @@ Send the following request (in the real world, the device makes this request). I
9293
--data-urlencode 'client_id={your application client id}' \
9394
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:device_code'
9495
95-
In `RFC`_ Figure 1, there are two options for step (F). Until the user enters the code in the browser and approves,
96-
the response will be 400:
96+
In `RFC`_ Figure 1, there are multiple options for step (F), as per `_RFC3.5`. Until the user enters the code in the browser
97+
and approves, the response will be 400:
9798

9899
.. code-block:: json
99100
100101
{"error": "authorization_pending"}
101102
102-
After the user approves, the response will be 200:
103+
The error could also be `access_denied` or `expired_token`.
104+
105+
However, after the user approves, the response will be 200:
103106

104107
.. code-block:: json
105108

oauth2_provider/models.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -698,10 +698,19 @@ class Meta:
698698

699699
def is_expired(self):
700700
"""
701-
Check device flow session expiration.
701+
Check device flow session expiration and set the status to "expired" if current time
702+
is past the "expires" deadline.
702703
"""
704+
if self.status == self.EXPIRED:
705+
return True
706+
703707
now = datetime.now(tz=dt_timezone.utc)
704-
return now >= self.expires
708+
if now >= self.expires:
709+
self.status = self.EXPIRED
710+
self.save(update_fields=["status"])
711+
return True
712+
713+
return False
705714

706715

707716
class DeviceManager(models.Manager):

oauth2_provider/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"),
1414
path("device-authorization/", views.DeviceAuthorizationView.as_view(), name="device-authorization"),
1515
path("device/", views.device_user_code_view, name="device"),
16-
path("device-confirm/<str:device_code>", views.device_confirm_view, name="device-confirm"),
16+
path("device-confirm/<str:client_id>/<str:user_code>", views.device_confirm_view, name="device-confirm"),
1717
]
1818

1919

oauth2_provider/views/base.py

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@
1313
from django.views.decorators.csrf import csrf_exempt
1414
from django.views.decorators.debug import sensitive_post_parameters
1515
from django.views.generic import FormView, View
16-
from oauthlib.oauth2.rfc8628.errors import (
17-
AccessDenied,
18-
AuthorizationPendingError,
19-
)
16+
from oauthlib.oauth2.rfc8628 import errors as rfc8628_errors
2017

2118
from oauth2_provider.models import Device
2219

@@ -320,34 +317,56 @@ def authorization_flow_token_response(
320317
def device_flow_token_response(
321318
self, request: http.HttpRequest, device_code: str, *args, **kwargs
322319
) -> http.HttpResponse:
323-
device = Device.objects.get(device_code=device_code)
320+
try:
321+
device = Device.objects.get(device_code=device_code)
322+
except Device.DoesNotExist:
323+
# The RFC does not mention what to return when the device is not found,
324+
# but to keep it consistent with the other errors, we return the error
325+
# in json format with an "error" key and the value formatted in the same
326+
# way.
327+
return http.HttpResponseNotFound(
328+
content='{"error": "device_not_found"}',
329+
content_type="application/json",
330+
)
324331

332+
# Here we are returning the errors according to
333+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
334+
# TODO: "slow_down" error (essentially rate-limiting).
325335
if device.status == device.AUTHORIZATION_PENDING:
326-
pending_error = AuthorizationPendingError()
327-
return http.HttpResponse(
328-
content=pending_error.json, status=pending_error.status_code, content_type="application/json"
336+
error = rfc8628_errors.AuthorizationPendingError()
337+
elif device.status == device.DENIED:
338+
error = rfc8628_errors.AccessDenied()
339+
elif device.status == device.EXPIRED:
340+
error = rfc8628_errors.ExpiredTokenError()
341+
elif device.status != device.AUTHORIZED:
342+
# It's technically impossible to get here because we've exhausted
343+
# all the possible values for status. However, it does act as a
344+
# reminder for developers when they add, in the future, new values
345+
# (such as slow_down) that they must handle here.
346+
return http.HttpResponseServerError(
347+
content='{"error": "internal_error"}',
348+
content_type="application/json",
329349
)
350+
else:
351+
# AUTHORIZED is the only accepted state, anything else is
352+
# rejected.
353+
error = None
330354

331-
if device.status == device.DENIED:
332-
access_denied_error = AccessDenied()
355+
if error:
333356
return http.HttpResponse(
334-
content=access_denied_error.json,
335-
status=access_denied_error.status_code,
357+
content=error.json,
358+
status=error.status_code,
336359
content_type="application/json",
337360
)
338361

339362
url, headers, body, status = self.create_token_response(request)
340-
341363
if status != 200:
342364
return http.JsonResponse(data=json.loads(body), status=status)
343365

344366
response = http.JsonResponse(data=json.loads(body), status=status)
345-
346367
for k, v in headers.items():
347368
response[k] = v
348369

349-
device.status = device.EXPIRED
350-
device.save(update_fields=["status"])
351370
return response
352371

353372
def post(self, request: http.HttpRequest, *args, **kwargs) -> http.HttpResponse:

oauth2_provider/views/device.py

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@
22

33
from django import forms, http
44
from django.contrib.auth.decorators import login_required
5+
from django.db.models import Q
56
from django.shortcuts import render
67
from django.urls import reverse
78
from django.utils.decorators import method_decorator
89
from django.views.decorators.csrf import csrf_exempt
910
from django.views.generic import View
1011
from oauthlib.oauth2 import DeviceApplicationServer
11-
from oauthlib.oauth2.rfc8628.errors import (
12-
AccessDenied,
13-
ExpiredTokenError,
14-
)
1512

1613
from oauth2_provider.compat import login_not_required
1714
from oauth2_provider.models import Device, DeviceCodeResponse, DeviceRequest, create_device, get_device_model
@@ -41,52 +38,71 @@ class DeviceForm(forms.Form):
4138
user_code = forms.CharField(required=True)
4239

4340

44-
# it's common to see in real world products
45-
# device flow's only asking the user to sign in after they input the
46-
# user code but since the user has to be signed in regardless to approve the
47-
# device login we're making the decision here to require being logged in
48-
# up front
4941
@login_required
5042
def device_user_code_view(request):
43+
"""
44+
The view where the user is instructed (by the device) to come to in order to
45+
enter the user code. More details in this section of the RFC:
46+
https://datatracker.ietf.org/doc/html/rfc8628#section-3.3
47+
48+
Note: it's common to see in other implementations of this RFC that only ask the
49+
user to sign in after they input the user code but since the user has to be signed
50+
in regardless, to approve the device login we're making the decision here, for
51+
simplicity, to require being logged in up front.
52+
"""
5153
form = DeviceForm(request.POST)
5254

5355
if request.method != "POST":
5456
return render(request, "oauth2_provider/device/user_code.html", {"form": form})
5557

5658
if not form.is_valid():
57-
return render(request, "oauth2_provider/device/user_code.html", {"form": form})
59+
form.add_error(None, "Form invalid")
60+
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400)
5861

5962
user_code: str = form.cleaned_data["user_code"]
60-
device: Device = get_device_model().objects.get(user_code=user_code)
63+
try:
64+
device: Device = get_device_model().objects.get(user_code=user_code)
65+
except Device.DoesNotExist:
66+
form.add_error("user_code", "Incorrect user code")
67+
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=404)
6168

6269
device.user = request.user
6370
device.save(update_fields=["user"])
6471

65-
if device is None:
66-
form.add_error("user_code", "Incorrect user code")
67-
return render(request, "oauth2_provider/device/user_code.html", {"form": form})
68-
6972
if device.is_expired():
70-
device.status = device.EXPIRED
71-
device.save(update_fields=["status"])
72-
raise ExpiredTokenError
73+
form.add_error("user_code", "Expired user code")
74+
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400)
7375

7476
# User of device has already made their decision for this device
75-
if device.status in (device.DENIED, device.AUTHORIZED):
76-
raise AccessDenied
77+
if device.status != device.AUTHORIZATION_PENDING:
78+
form.add_error("user_code", "User code has already been used")
79+
return render(request, "oauth2_provider/device/user_code.html", {"form": form}, status=400)
7780

7881
# 308 to indicate we want to keep the redirect being a POST request
7982
return http.HttpResponsePermanentRedirect(
80-
reverse("oauth2_provider:device-confirm", kwargs={"device_code": device.device_code}), status=308
83+
reverse(
84+
"oauth2_provider:device-confirm",
85+
kwargs={"client_id": device.client_id, "user_code": user_code},
86+
),
87+
status=308,
8188
)
8289

8390

8491
@login_required
85-
def device_confirm_view(request: http.HttpRequest, device_code: str):
86-
device: Device = get_device_model().objects.get(device_code=device_code)
87-
88-
if device.status in (device.AUTHORIZED, device.DENIED):
89-
return http.HttpResponse("Invalid")
92+
def device_confirm_view(request: http.HttpRequest, client_id: str, user_code: str):
93+
try:
94+
device: Device = get_device_model().objects.get(
95+
# there is a db index on client_id
96+
Q(client_id=client_id) & Q(user_code=user_code)
97+
)
98+
except Device.DoesNotExist:
99+
return http.HttpResponseNotFound("<h1>Device not found</h1>")
100+
101+
if device.status != device.AUTHORIZATION_PENDING:
102+
# AUTHORIZATION_PENDING is the only accepted state, anything else implies
103+
# that the user already approved/denied OR the deadline has passed (aka
104+
# expired)
105+
return http.HttpResponseBadRequest("Invalid")
90106

91107
action = request.POST.get("action")
92108

tests/app/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ password: password
2929

3030
You can update data in the IDP and then dump the data to a new seed file as follows.
3131

32-
```
32+
```
3333
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
34-
```
34+
```
3535

3636
## /test/app/rp
3737

0 commit comments

Comments
 (0)