Skip to content
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

Draft
wants to merge 27 commits into
base: master
Choose a base branch
from

Conversation

duzumaki
Copy link

@duzumaki duzumaki commented Jan 7, 2025

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

follow up from
oauthlib/oauthlib#881
&
oauthlib/oauthlib#889

Description of the Change

Checklist

  • PR only contains one change (considered splitting up PR)
  • unit-test added
  • documentation updated
  • CHANGELOG.md updated (only for user relevant changes)
  • author name in AUTHORS

@duzumaki duzumaki force-pushed the add-device-flow branch 2 times, most recently from 85991ac to 87bbf79 Compare January 7, 2025 16:04
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)
@duzumaki duzumaki force-pushed the add-device-flow branch 3 times, most recently from d94410c to acc1753 Compare January 7, 2025 16:11
Copy link
Contributor

@dopry dopry left a 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):
Copy link
Contributor

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?

Copy link

@danlamanna danlamanna left a 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.

  1. 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")
  1. Likewise, are downstreams expected to implement their own /token endpoint?

  2. 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")

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?

Copy link
Author

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",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
name="unique_device_code",
name="%(app_label)s_%(class)s_unique_device_code",


@dataclass
class DeviceCodeResponse:
verification_uri: str

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.

Copy link
Author

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 = {

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.

Copy link
Author

@duzumaki duzumaki Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a better spot, yup. I'll test it out. Didn't see that method until now

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)

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?

Copy link
Author

@duzumaki duzumaki Jan 10, 2025

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):

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?

Copy link
Author

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:

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?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this originally and don't remember why I removed it (been on and off this branch for the last 2-3 months so forgot some of my initial decisions). I'll play around with adding it back in and maybe re-jog why I removed it (or not)

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}))

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):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise maybe DeviceGrant?

Copy link
Author

@duzumaki duzumaki Jan 10, 2025

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)

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.

Copy link
Author

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

@duzumaki
Copy link
Author

duzumaki commented Jan 10, 2025

@danlamanna

  1. 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")

This code I put in tutotorial_06.rst was a simplified version of how I implemented in my own authserver.
However, with the device view being highly customizable and specific to your own auth server and some implementations even working with open id connect(which is not part of the rfc), I opted to not include it. For example, I have plans on adding extensions similar to mutual TLS to my device flow in my auth server.

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). Can always incrementally update django oauth toolkit but I would like to get the core tooling in first

  1. Likewise, are downstreams expected to implement their own /token endpoint?

No , they can if they want but oauth toolkit provides that endpoint. They just need to have a working /token endpoint as a prerequisite

  1. 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.

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 :)

Thanks again for all this work :)

Thank you!


@pytest.mark.usefixtures("oauth2_settings")
@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW)
class BaseTest(TestCase):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please rename DeviceFlowBaseTestCase

@dopry
Copy link
Contributor

dopry commented Jan 12, 2025

@danlamanna thanks for putting it through it's paces and for the code review. We always appreciate extra hands in the community kicking the tires on pull requests.

@duzumaki I haven't had a chance to get into a thorough review yet. It's high on my OSS priority list. It would be nice to have a working implementation in the example idp/rp in tests/app. If you need any help on the rp side there, I'm happy to lend a hand. That will reduce our testing overhead as maintainers. It's a lot to review an OAuth Flow without also having to implement part of it as well, especially as we haven't been as awash in the specification as you seem to have been for a bit. I am partial to the idea of having necessary default views in DOT, I really prefer as much of a batteries included experience for our users. If we give people a half implementation in an initial release it will be a lot of work for a lot of people, then when we add in our own view implementations it'll be an upgrade headache for all of those users. If we can deliver a view that adheres to best practice with reasonable defaults which users can override I would much prefer that.

@duzumaki
Copy link
Author

duzumaki commented Jan 12, 2025

@dopry

makes sense. i'll port some implementation i had in my own custom auth server over to this pr

@duzumaki duzumaki force-pushed the add-device-flow branch 7 times, most recently from cd79c50 to 04f6ccc Compare January 14, 2025 17:02
@n2ygk
Copy link
Member

n2ygk commented Jan 14, 2025

@duzumaki It looks like you may be battling with pre-commit which is fixing your code formatting after push. Do you have pre-commit installed locally? See https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html#code-style

@duzumaki
Copy link
Author

@n2ygk The pushes aren't because of the pre-commit. I use rebase so I'm fixing the history so it's easier to review

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
why?

DOT currently assume the user will be derived from the django request.user object (from the logic throughout DOT, not the model itself).

Since the device flow happens out of band there is no request.user available when the call to token is made, we have to make this field none.

How do I handle it in my own custom auth server:
In my custom auth server how I associate a refresh token with a user is to have a field (column in the refresh token table) that has the payload of the original JWT what was made when the refresh token was issued and I use the sub claim in the payload to know “this user has the refresh token” which prevents it relying on django solely for the user information but the stateless JWT instead
A public device code grant doesn't have a client_secret to check
Needed since the token endpoint when the device is polling it
needs to be rate limited
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
This creates a user friendly but still high entropy user code to be used
in the device flow
@duzumaki
Copy link
Author

duzumaki commented Jan 14, 2025

@n2ygk @dopry @danlamanna just added new commits that add everything needed to test the device flow end to end + a test that tests the whole flow touching all of the relevant views.

again, reviewing the commits, commit by commit, will help versus looking at all files changed at once(during your first pass review anyway)

@danlamanna I haven't addressed all of your comments yet. I just want to ensure we all agree on the complete set of views I've just added first then I'll go back to handle the smaller stuff you commented on

@dopry
In terms of the idp test app please see the updated doc here that provides instructions on starting the idp and shows everything step by step. I opted out from using the rp part because it's easier to just copy and paste the curl command I put in it and run it against the local server and the device flow is out of band anyway so in a sense doing it from your terminal is the "device" so it's a closer experience

all checks look good locally too
image

@duzumaki duzumaki requested review from dopry and danlamanna January 14, 2025 17:50
@dopry
Copy link
Contributor

dopry commented Jan 14, 2025

I'll take a look later this evening

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Device Authorization Grant
4 participants