-
-
Notifications
You must be signed in to change notification settings - Fork 797
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add device authorization grant (device code flow - rfc 8628) #1539
base: master
Are you sure you want to change the base?
Conversation
85991ac
to
87bbf79
Compare
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)
d3988ee
to
377c07d
Compare
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
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
The device flow is initiated by sending the client_id and and a scope. This check should not fail if the client is public
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
This view is to be used in an authorization server in order to provide a /device endpoint
The grant type for device code is 44 characters
This commit will not be merged(I think). Currently oauthlib is due a release so I'm pointing this to master
7106e6f
to
816db6e
Compare
d94410c
to
acc1753
Compare
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks excellent, Only one thing grabbed my attention in my cursory code review, the type of the request parameter. Take a moment to double check that type. I've been bitten by OAuthLib's recasting of Request on a number of occasions. I hope to get time to more thoroughly review this by the end of the week
@@ -148,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): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure this is a django.http.HttpRequest and not an oauthlib.common.Request?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks awesome! I left some comments even though I'm not a maintainer, I'm just an excited downstream user :). If you're too busy to address any of my feedback let me know, I'd be happy to spend some time on it.
I got this up and running locally and was able to complete the authorization flow. Other than the comments I left inline, I have a few thoughts.
- Were you planning on adding a default view and template to complete the flow, similar to the way other grant types operate? Obviously the device flow user interaction can be highly customized, but I think a simple view could provide a decent out of the box experience. This was the code I wrote on my application to test this end-to-end:
from oauthlib.oauth2.rfc8628.errors import (
AccessDenied,
ExpiredTokenError,
)
from oauth2_provider.models import get_device_model
from django import forms
class DeviceForm(forms.Form):
user_code = forms.CharField(required=True)
@login_required
def oauth_device_authenticate(request):
form = DeviceForm(request.POST or None)
if request.method == "POST" and form.is_valid():
user_code = form.cleaned_data["user_code"]
device = get_device_model().objects.filter(user_code=user_code).first()
if device is None:
form.add_error("user_code", "Incorrect user code")
else:
if timezone.now() > device.expires:
device.status = device.EXPIRED
device.save(update_fields=["status"])
raise ExpiredTokenError
if device.status in (device.DENIED, device.AUTHORIZED):
raise AccessDenied
if device.user_code == user_code:
device.status = device.AUTHORIZED
device.save(update_fields=["status"])
return HttpResponseRedirect(reverse("oauth-device-authenticate-success"))
return render(request, "device_authenticate.html", {"form": form})
@login_required
def oauth_device_authenticate_success(request):
return render(request, "device_authenticate_success.html")
-
Likewise, are downstreams expected to implement their own
/token
endpoint? -
Should DOT be a little bit more opinionated about how to generate things like
user_code
? There seems to be a good bit in the RFC (6.1) about best practices that we could encode for downstreams: e.g. using a shorter code with enough entropy that has readable characters and is compared case-insensitively.
Thanks again for all this work :)
id = models.BigAutoField(primary_key=True) | ||
device_code = models.CharField(max_length=100, unique=True) | ||
user_code = models.CharField(max_length=100) | ||
scope = models.CharField(max_length=64, default="openid") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is scope mandatory and defaults to openid
? Is there a reason it should deviate from what AbstractGrant
does?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This might be a remnant from when I first started this work; I wanted to integrate it with openid but decided to keep it just focused to oauth2, the official rfc. I'll double check this and try remove it. also this
constraints = [ | ||
models.UniqueConstraint( | ||
fields=["device_code"], | ||
name="unique_device_code", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
name="unique_device_code", | |
name="%(app_label)s_%(class)s_unique_device_code", |
|
||
@dataclass | ||
class DeviceCodeResponse: | ||
verification_uri: str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should there perhaps be some way of configuring verification_uri_complete
similar to verification_uri
? That way clients wanting to use a QR code won't have to do their own URI assembly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah can do
instance. | ||
:param request: The current django.http.HttpRequest object | ||
""" | ||
oauth2_settings.EXTRA_SERVER_KWARGS = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be moved into OAuth2ProviderSettings.server_kwargs where the rest of these are set? It seems like this might cause issues depending on the order of requests when the OAuthlibCore
instance is cached.
headers, response, status = self.create_device_authorization_response(request) | ||
|
||
device_request = DeviceRequest( | ||
client_id=request.POST["client_id"], scope=request.POST.get("scope", oauth2_settings.READ_SCOPE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this default to leaving the scope empty?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll have to double check this because I remember when I first started this work something in oauthlib was failing since no scope was present and it wasn't a check I added there
@@ -650,11 +654,93 @@ class Meta(AbstractIDToken.Meta): | |||
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" | |||
|
|||
|
|||
class AbstractDevice(models.Model): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this should be named AbstractDeviceGrant
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
# there should only be one | ||
device: Device = get_device_model().objects.get(user_code=user_code) | ||
if datetime.now(tz=UTC) > device.expires: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the Device
/DeviceGrant
have an is_expired
method similar to Grant
so downstreams don't have to reimplement?
return http.HttpResponseRedirect(...) | ||
# user is logged in and typed the user code in correctly. redirect to the the approve deny endpoint now | ||
return http.HttpResponseRedirect(reverse("device-confirm", kwargs={"device_code": device.device_code})) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This issues a redirect but the example endpoint expects POST
data.
return self.get(client_id=client_id, device_code=device_code, user_code=user_code) | ||
|
||
|
||
class Device(AbstractDevice): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Likewise maybe DeviceGrant
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not the grant, it's the model that represents the device during the flow's session,
this is the device grant
status = models.CharField( | ||
max_length=64, blank=True, choices=DEVICE_FLOW_STATUS, default=AUTHORIZATION_PENDING | ||
) | ||
client_id = models.CharField(max_length=100, default=generate_client_id, db_index=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason this generates its own client_id
instead of pointing to an Application
that has one? I'm not too familiar with this part of the codebase so feel free to ignore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this default shouldn't be needed
This code I put in tutotorial_06.rst was a simplified version of how I implemented in my own authserver. However this is up to the maintainers to decide but I'd rather get this merged and we add it later if we deem it important as I also worked on making sure oauthlib can support this grant so I've been working on this for quite some time now to put everything in place(this pr & this)
No , they can if they want but oauth toolkit provides that endpoint. They just need to have a working
That's why I updated oauthlib to support the ability to pass in custom user code generator callables if you set the setting I made for it in oauth toolkit. I'm being core RFC focused here first and if anything opinionated needs to be added I think we can add it later, This pr is already chunky as is the way I see it. Nothing stopping us from releasing inceremental updates here instead of one big bang :)
Thank you! |
Note to reviewers: I've made this a "commit by commit" pr which means it's easier to review the pr if you go commit by commit rather than look at all files changed at once
Fixes #962
Description of the Change
Checklist
CHANGELOG.md
updated (only for user relevant changes)AUTHORS