Skip to content

Commit 028c563

Browse files
duzumakiauvipy
andauthored
Add DeviceCodeGrant type for device code flow(rfc8628) section 3.4 & 3.5 (#889)
* Add device grant specific errors These are from section 3.5 of the rfc https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 * Add device code grant handler In the device flow, the device must poll the token endpoint to check if the user has submitted the user_code. This commit adds that handler to validate the request and return the token. * Allow interval to be controlled by caller Any caller of DeviceApplicationServer() needs control of the inerval polling time. This is part of the flow where the device polls the auth server to check in the user has submitted the user_code * Use absolute imports Easier to read, easier to derive where the module lives. * Add device grant to oauth2 based server This grant type handler exists in both the openid conenct server and the oauth 2.0 server. Why? It sets up for a potentitial open id over device flow implementation. It also alo ensures the access_token and refresh_token still get returned whether openid is enabled or not. device flow isn't a spec that's part of open id but some IDps like microsoft, auth0, okta implement an off-spec version. However, that's not the concern of the pr this commit is apart of but if returning an id_token(openid) is needed down the line that will have to be a seperate issue rasied. * Add interval default to align with rfc * Add device code grant tests * Add device code grant docs * Apply ruff format using line-length 99 The 255 is too high, leads to lines that are too long. I'll get a pr to format the entire repo after this one * Add device code grant to support grant claiims * Add pseudocode example of how to implement the device flow * Update tests/oauth2/rfc8628/grant_types/test_device_code.py --------- Co-authored-by: Asif Saif Uddin <auvipy@gmail.com>
1 parent 1fd5253 commit 028c563

File tree

14 files changed

+813
-107
lines changed

14 files changed

+813
-107
lines changed

docs/oauth2/grants/device_code.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Device code Grant
2+
-----------------
3+
4+
.. autoclass:: oauthlib.oauth2.DeviceCodeGrant
5+
:members:
6+
:inherited-members:
7+
8+
9+
An pseudocode/skeleton example of how the device flow can be implemented is
10+
available in the `examples`_ folder on GitHub.
11+
12+
.. _`examples`: https://github.com/oauthlib/oauthlib/blob/master/examples/device_code_flow.py

docs/oauth2/grants/grants.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Grant types
1010
password
1111
credentials
1212
refresh
13+
device_code
1314
jwt
1415
custom_validators
1516
custom_grant
@@ -26,6 +27,13 @@ degree of trust between the resource owner and the client, and when
2627
other authorization grant types are not available. This is also often
2728
used for legacy applications to incrementally transition to OAuth 2.
2829

30+
The device code grant(officially referred to as 'urn:ietf:params:oauth:grant-type:device_code')
31+
is used when trying to authenticate device with limited or no input capabilities by getting
32+
the user to approve the login on an external device (like a mobile phone or laptop) in their
33+
possession that they're already logged into. Unlike the previously mentioned grants it is an extension grant, which is a type of grant
34+
to address specific authorization scenarios.
35+
:doc:`Device code grant </oauth2/grants/device_code>`
36+
2937
The main purpose of the grant types is to authorize access to protected
3038
resources in various ways with different security credentials.
3139

examples/device_code_flow.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import enum
2+
import json
3+
import datetime
4+
from datetime import timedelta
5+
6+
from oauthlib.oauth2 import RequestValidator, Server, DeviceApplicationServer
7+
from oauthlib.oauth2.rfc8628 import errors as device_flow_errors
8+
from oauthlib.oauth2.rfc8628.errors import AccessDenied, AuthorizationPendingError, ExpiredTokenError, SlowDownError
9+
10+
11+
"""
12+
A pseudocode implementation of the device flow code under an Oauth2 provider.
13+
14+
This example is not concerned with openid in any way.
15+
16+
This example is also not a 1:1 pseudocode implementation. Please refer to the rfc
17+
for the full details.
18+
https://datatracker.ietf.org/doc/html/rfc8628
19+
20+
This module is just acting as a way to demonstrate the main pieces
21+
needed in oauthlib to implement the flow
22+
23+
24+
We also assume you already have the /token & /login endpoint in your provider.
25+
26+
Your provider will also need the following endpoints(which will be discussed
27+
in the example below):
28+
- /device_authorization (part of rfc)
29+
- /device (part of rfc)
30+
- /approve-deny (up to your implementation, this is an example)
31+
"""
32+
33+
34+
"""
35+
Device flow pseudocode implementation step by step:
36+
0. Providing some way to represent the device flow session
37+
38+
Some python object to represent the current state of the device during
39+
the device flow. This, for example, could be an object that persists
40+
and represents the device in a database
41+
"""
42+
43+
44+
class Device:
45+
class DeviceFlowStatus(enum.Enum):
46+
AUTHORIZED = "Authorized"
47+
AUTHORIZATION_PENDING = "Authorization_pending"
48+
EXPIRED = "Expired"
49+
DENIED = "Denied"
50+
51+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
52+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.4
53+
id = ... # if Device is representing a database object, this will be the id of that row
54+
device_code = ...
55+
user_code = ...
56+
scope = ...
57+
interval = ... # in seconds, default is 5
58+
expires = ... # seconds
59+
status = ... # DeviceFlowStatus with AUTHORIZATION_PENDING as the default
60+
61+
client_id = ...
62+
last_checked = ... # datetime
63+
64+
65+
"""
66+
1. User goes on their device(client) and the client sends a request to /device_authorization
67+
against the provider:
68+
https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
69+
https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
70+
71+
72+
POST /device_authorization HTTP/1.1
73+
Host: server.example.com
74+
Content-Type: application/x-www-form-urlencoded
75+
76+
client_id=1406020730&scope=example_scope
77+
78+
Response:
79+
HTTP/1.1 200 OK
80+
Content-Type: application/json
81+
Cache-Control: no-store
82+
83+
{
84+
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
85+
"user_code": "WDJB-MJHT",
86+
"verification_uri": "https://example.com/device",
87+
"verification_uri_complete":
88+
"https://example.com/device?user_code=WDJB-MJHT",
89+
"expires_in": 1800,
90+
"interval": 5
91+
}
92+
"""
93+
94+
95+
class DeviceAuthorizationEndpoint:
96+
@staticmethod
97+
def create_device_authorization_response(request):
98+
server = DeviceApplicationServer(interval=5, verification_uri="https://example.com/device")
99+
return server.create_device_authorization_response(request)
100+
101+
def post(self, request):
102+
headers, data, status = self.create_device_authorization_response(request)
103+
device_response = ...
104+
105+
# Create an instance of examples.device_flow.Device` using `request` and `data`that encapsulates
106+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 &
107+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
108+
109+
return device_response
110+
111+
112+
"""
113+
2. Client presents the information to the user
114+
(There's a section on non visual capable devices as well
115+
https://datatracker.ietf.org/doc/html/rfc8628#section-5.7)
116+
+-------------------------------------------------+
117+
| |
118+
| Scan the QR code or, using +------------+ |
119+
| a browser on another device, |[_].. . [_]| |
120+
| visit: | . .. . .| |
121+
| https://example.com/device | . . . ....| |
122+
| |. . . . | |
123+
| And enter the code: |[_]. ... . | |
124+
| WDJB-MJHT +------------+ |
125+
| |
126+
+-------------------------------------------------+
127+
"""
128+
# The implementation for step 2 is up to the owner of device.
129+
130+
131+
""""
132+
3 (The browser flow). User goes to https://example.com/device where they're presented with a
133+
form to fill in the user code.
134+
135+
Implement that endpoint on your provider and follow the logic in the rfc.
136+
137+
Making use of the errors in `oauthlib.oauth2.rfc8628.errors`
138+
139+
raise AccessDenied/AuthorizationPendingError/ExpiredTokenError where appropriate making use of
140+
`examples.device_flow.Device` to get and update current state of the device during the session
141+
142+
If the user isn't logged in(after inputting the user-code), they should be redirected to the provider's /login
143+
endpoint and redirected back to an /approve-deny endpoint(The name and implementation of /approve-deny is up
144+
to the owner of the provider, this is just an example).
145+
They should then see an "approve" or "deny" button to authorize the device.
146+
147+
Again, using `examples.device_flow.Device` to update the status appropriately during the session.
148+
"""
149+
# /device and /approve-deny is up to the owner of the provider to implement. Again, make sure to
150+
# keep referring to the rfc when implementing.
151+
152+
153+
"""
154+
4 (The polling flow)
155+
https://datatracker.ietf.org/doc/html/rfc8628#section-3.4
156+
https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
157+
158+
159+
Right after step 2, the device polls the /token endpoint every "interval" amount of seconds
160+
to check if user has approved or denied the request.
161+
162+
When grant type is `urn:ietf:params:oauth:grant-type:device_code`,
163+
`oauthlib.oauth2.rfc8628.grant_types.device_code.DeviceCodeGrant` will be the handler
164+
that handles token generation.
165+
"""
166+
167+
168+
# This is purely for illustrative purposes
169+
# to demonstrate rate limiting on the token endpoint for the device flow.
170+
# It is up to as the provider to decide how you want
171+
# to rate limit the device during polling.
172+
def rate_limit(func, rate="1/5s"):
173+
def wrapper():
174+
# some logic to ensure client device is rate limited by a minimum
175+
# of 1 request every 5 seconds during device polling
176+
# https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
177+
178+
# use device_code to retrieve device
179+
device = Device
180+
181+
# get the time in seconds since the device polled the /token endpoint
182+
now = datetime.datetime.now(tz=datetime.UTC)
183+
diff = now - timedelta(device.last_checked)
184+
total_seconds_since_last_device_poll = diff.total_seconds()
185+
186+
device.last_checked = now
187+
188+
# for illustrative purposes. 1/5s means 1 request every 5 seconds.
189+
# so if `total_seconds_since_last_device_poll` is 4 seconds, this will
190+
# raise an error
191+
if total_seconds_since_last_device_poll < rate:
192+
raise device_flow_errors.SlowDownError()
193+
194+
result = func()
195+
return result
196+
197+
return wrapper
198+
199+
200+
class ExampleRequestValidator(RequestValidator):
201+
# All the other methods that need to be implemented...
202+
# see examples.skeleton_oauth2_web_application_server.SkeletonValidator
203+
# for a more complete example.
204+
205+
# Here our main concern is this method:
206+
def create_token_response(self): ...
207+
208+
209+
class ServerSetupForTokenEndpoint:
210+
def __init__(self):
211+
validator = ExampleRequestValidator
212+
self.server = Server(validator)
213+
214+
215+
# You should already have the /token endpoint implemented in your provider.
216+
class TokenEndpoint(ServerSetupForTokenEndpoint):
217+
def default_flow_token_response(self, request):
218+
url, headers, body, status = self.server.create_token_response(request)
219+
access_token = json.loads(body).get("access_token")
220+
221+
# return access_token in a http response
222+
return access_token
223+
224+
@rate_limit # this will raise the SlowDownError
225+
def device_flow_token_response(self, request, device_code):
226+
"""
227+
Following the rfc, this will route the device request accordingly and raise
228+
required errors.
229+
230+
Remember that unlike other auth flows, the device if polling this endpoint once
231+
every "interval" amount of seconds.
232+
"""
233+
# using device_code arg to retrieve the correct device object instance
234+
device = Device
235+
236+
if device.status == device.DeviceFlowStatus.AUTHORIZATION_PENDING:
237+
raise AuthorizationPendingError()
238+
239+
# If user clicked "deny" in the /approve-deny page endpoint.
240+
# the device gets set to 'authorized' in /approve-deny and /device checks
241+
# if someone tries to input a code for a user code that's already been authorized
242+
if device.status == device.DeviceFlowStatus.DENIED:
243+
raise AccessDenied()
244+
245+
url, headers, body, status = self.server.create_token_response(request)
246+
247+
access_token = json.loads(body).get("access_token")
248+
249+
device.status = device.EXPIRED
250+
251+
# return access_token in a http response
252+
return access_token
253+
254+
# Example of how token endpoint could handle the token creation depending on
255+
# the grant type during a POST to /token.
256+
def post(self, request):
257+
params = request.POST
258+
if params.get("grant_type") == "urn:ietf:params:oauth:grant-type:device_code":
259+
return self.device_flow_token_response(request, params["device_code"])
260+
return self.default_flow_token_response(request)

oauthlib/oauth2/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,5 @@
6666
from .rfc6749.tokens import BearerToken, OAuth2Token
6767
from .rfc6749.utils import is_secure_transport
6868
from .rfc8628.clients import DeviceClient
69-
from .rfc8628.endpoints import DeviceAuthorizationEndpoint, DeviceApplicationServer
69+
from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint, DeviceApplicationServer
70+
from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy