Skip to content

Commit

Permalink
Add support for device authorization flow (RFC8628) (#795)
Browse files Browse the repository at this point in the history
* rfc8628: Add client implementation for token retrieval

This change adds an implementation of the Device Authorization flow
client from RFC8628.  The initial structure is derived from the
existing BackendApplicationClient with the addition of the device_code
in the client.

This change does not provide the support necessary for querying the
device code endpoint in order to generate the initial device_code and
URL that is required for completing the full end to end device
authorization process.

* Add device token fetch URI generator

In order to perform the full device authorization flow it's necessary
to first generate the device code and get the authorization flow URL.

prepare_request_uri() allows us to do this while providing scopes and
additional parameters.

* Remove encoding lines

These lines are not required for python3
  • Loading branch information
kellyma2 committed Jan 18, 2022
1 parent 553850b commit c3e8787
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 0 deletions.
5 changes: 5 additions & 0 deletions docs/oauth2/clients/deviceclient.rst
@@ -0,0 +1,5 @@
DeviceClient
------------------------

.. autoclass:: oauthlib.oauth2.DeviceClient
:members:
1 change: 1 addition & 0 deletions oauthlib/oauth2/__init__.py
Expand Up @@ -33,3 +33,4 @@
from .rfc6749.request_validator import RequestValidator
from .rfc6749.tokens import BearerToken, OAuth2Token
from .rfc6749.utils import is_secure_transport
from .rfc8628.clients import DeviceClient
10 changes: 10 additions & 0 deletions oauthlib/oauth2/rfc8628/__init__.py
@@ -0,0 +1,10 @@
"""
oauthlib.oauth2.rfc8628
~~~~~~~~~~~~~~~~~~~~~~~
This module is an implementation of various logic needed
for consuming and providing OAuth 2.0 Device Authorization RFC8628.
"""
import logging

log = logging.getLogger(__name__)
8 changes: 8 additions & 0 deletions oauthlib/oauth2/rfc8628/clients/__init__.py
@@ -0,0 +1,8 @@
"""
oauthlib.oauth2.rfc8628
~~~~~~~~~~~~~~~~~~~~~~~
This module is an implementation of various logic needed
for consuming OAuth 2.0 Device Authorization RFC8628.
"""
from .device import DeviceClient
94 changes: 94 additions & 0 deletions oauthlib/oauth2/rfc8628/clients/device.py
@@ -0,0 +1,94 @@
"""
oauthlib.oauth2.rfc8628
~~~~~~~~~~~~~~~~~~~~~~~
This module is an implementation of various logic needed
for consuming and providing OAuth 2.0 Device Authorization RFC8628.
"""

from oauthlib.oauth2 import BackendApplicationClient, Client
from oauthlib.oauth2.rfc6749.errors import InsecureTransportError
from oauthlib.oauth2.rfc6749.parameters import prepare_token_request
from oauthlib.oauth2.rfc6749.utils import is_secure_transport, list_to_scope
from oauthlib.common import add_params_to_uri


class DeviceClient(Client):

"""A public client utilizing the device authorization workflow.
The client can request an access token using a device code and
a public client id associated with the device code as defined
in RFC8628.
The device authorization grant type can be used to obtain both
access tokens and refresh tokens and is intended to be used in
a scenario where the device being authorized does not have a
user interface that is suitable for performing authentication.
"""

grant_type = 'urn:ietf:params:oauth:grant-type:device_code'

def __init__(self, client_id, **kwargs):
super().__init__(client_id, **kwargs)
self.client_secret = kwargs.get('client_secret')

def prepare_request_uri(self, uri, scope=None, **kwargs):
if not is_secure_transport(uri):
raise InsecureTransportError()

scope = self.scope if scope is None else scope
params = [(('client_id', self.client_id)), (('grant_type', self.grant_type))]

if self.client_secret is not None:
params.append(('client_secret', self.client_secret))

if scope:
params.append(('scope', list_to_scope(scope)))

for k in kwargs:
if kwargs[k]:
params.append((str(k), kwargs[k]))

return add_params_to_uri(uri, params)

def prepare_request_body(self, device_code, body='', scope=None,
include_client_id=False, **kwargs):
"""Add device_code to request body
The client makes a request to the token endpoint by adding the
device_code as a parameter using the
"application/x-www-form-urlencoded" format to the HTTP request
body.
:param body: Existing request body (URL encoded string) to embed parameters
into. This may contain extra paramters. Default ''.
:param scope: The scope of the access request as described by
`Section 3.3`_.
:param include_client_id: `True` to send the `client_id` in the
body of the upstream request. This is required
if the client is not authenticating with the
authorization server as described in
`Section 3.2.1`_. False otherwise (default).
:type include_client_id: Boolean
:param kwargs: Extra credentials to include in the token request.
The prepared body will include all provided device_code as well as
the ``grant_type`` parameter set to
``urn:ietf:params:oauth:grant-type:device_code``::
>>> from oauthlib.oauth2 import BackendApplicationClient
>>> client = DeviceClient('your_id', 'your_code')
>>> client.prepare_request_body(scope=['hello', 'world'])
'grant_type=urn:ietf:params:oauth:grant-type:device_code&scope=hello+world'
.. _`Section 3.4`: https://datatracker.ietf.org/doc/html/rfc8628#section-3.4
"""

kwargs['client_id'] = self.client_id
kwargs['include_client_id'] = include_client_id
scope = self.scope if scope is None else scope
return prepare_token_request(self.grant_type, body=body, device_code=device_code,
scope=scope, **kwargs)
Empty file.
Empty file.
63 changes: 63 additions & 0 deletions tests/oauth2/rfc8628/clients/test_device.py
@@ -0,0 +1,63 @@
import os
from unittest.mock import patch

from oauthlib import signals
from oauthlib.oauth2 import DeviceClient

from tests.unittest import TestCase


class DeviceClientTest(TestCase):

client_id = "someclientid"
kwargs = {
"some": "providers",
"require": "extra arguments"
}

client_secret = "asecret"

device_code = "somedevicecode"

scope = ["profile", "email"]

body = "not=empty"

body_up = "not=empty&grant_type=urn:ietf:params:oauth:grant-type:device_code"
body_code = body_up + "&device_code=somedevicecode"
body_kwargs = body_code + "&some=providers&require=extra+arguments"

uri = "https://example.com/path?query=world"
uri_id = uri + "&client_id=" + client_id
uri_grant = uri_id + "&grant_type=urn:ietf:params:oauth:grant-type:device_code"
uri_secret = uri_grant + "&client_secret=asecret"
uri_scope = uri_secret + "&scope=profile+email"

def test_request_body(self):
client = DeviceClient(self.client_id)

# Basic, no extra arguments
body = client.prepare_request_body(self.device_code, body=self.body)
self.assertFormBodyEqual(body, self.body_code)

rclient = DeviceClient(self.client_id)
body = rclient.prepare_request_body(self.device_code, body=self.body)
self.assertFormBodyEqual(body, self.body_code)

# With extra parameters
body = client.prepare_request_body(
self.device_code, body=self.body, **self.kwargs)
self.assertFormBodyEqual(body, self.body_kwargs)

def test_request_uri(self):
client = DeviceClient(self.client_id)

uri = client.prepare_request_uri(self.uri)
self.assertURLEqual(uri, self.uri_grant)

client = DeviceClient(self.client_id, client_secret=self.client_secret)
uri = client.prepare_request_uri(self.uri)
self.assertURLEqual(uri, self.uri_secret)

uri = client.prepare_request_uri(self.uri, scope=self.scope)
self.assertURLEqual(uri, self.uri_scope)

0 comments on commit c3e8787

Please sign in to comment.