From 0158e285a75ddbec9ebed3b2000d5e337ef4af66 Mon Sep 17 00:00:00 2001 From: Michael Kelly Date: Wed, 22 Dec 2021 08:53:43 -0800 Subject: [PATCH 1/3] 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. --- docs/oauth2/clients/deviceclient.rst | 5 ++ oauthlib/oauth2/__init__.py | 1 + oauthlib/oauth2/rfc8628/__init__.py | 10 +++ oauthlib/oauth2/rfc8628/clients/__init__.py | 9 +++ oauthlib/oauth2/rfc8628/clients/device.py | 69 +++++++++++++++++++++ tests/oauth2/rfc8628/__init__.py | 0 tests/oauth2/rfc8628/clients/__init__.py | 0 tests/oauth2/rfc8628/clients/test_device.py | 41 ++++++++++++ 8 files changed, 135 insertions(+) create mode 100644 docs/oauth2/clients/deviceclient.rst create mode 100644 oauthlib/oauth2/rfc8628/__init__.py create mode 100644 oauthlib/oauth2/rfc8628/clients/__init__.py create mode 100644 oauthlib/oauth2/rfc8628/clients/device.py create mode 100644 tests/oauth2/rfc8628/__init__.py create mode 100644 tests/oauth2/rfc8628/clients/__init__.py create mode 100644 tests/oauth2/rfc8628/clients/test_device.py diff --git a/docs/oauth2/clients/deviceclient.rst b/docs/oauth2/clients/deviceclient.rst new file mode 100644 index 00000000..d4e8d7de --- /dev/null +++ b/docs/oauth2/clients/deviceclient.rst @@ -0,0 +1,5 @@ +DeviceClient +------------------------ + +.. autoclass:: oauthlib.oauth2.DeviceClient + :members: diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index a6e1cccd..deefb1af 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -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 diff --git a/oauthlib/oauth2/rfc8628/__init__.py b/oauthlib/oauth2/rfc8628/__init__.py new file mode 100644 index 00000000..531929dc --- /dev/null +++ b/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__) diff --git a/oauthlib/oauth2/rfc8628/clients/__init__.py b/oauthlib/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 00000000..38e4e618 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/clients/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-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 diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py new file mode 100644 index 00000000..40451ab4 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/clients/device.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +""" +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.parameters import prepare_token_request + + +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 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) diff --git a/tests/oauth2/rfc8628/__init__.py b/tests/oauth2/rfc8628/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oauth2/rfc8628/clients/__init__.py b/tests/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/oauth2/rfc8628/clients/test_device.py b/tests/oauth2/rfc8628/clients/test_device.py new file mode 100644 index 00000000..597b3ab3 --- /dev/null +++ b/tests/oauth2/rfc8628/clients/test_device.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +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" + } + + 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" + + device_code = 'somedevicecode' + + 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) From 24ad1dc9e3e91ffc81d35c3dafe8c6edeabb5830 Mon Sep 17 00:00:00 2001 From: Michael Kelly Date: Wed, 22 Dec 2021 09:37:46 -0800 Subject: [PATCH 2/3] 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. --- oauthlib/oauth2/rfc8628/clients/device.py | 26 +++++++++++++++++++++ tests/oauth2/rfc8628/clients/test_device.py | 25 +++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py index 40451ab4..f422b6bb 100644 --- a/oauthlib/oauth2/rfc8628/clients/device.py +++ b/oauthlib/oauth2/rfc8628/clients/device.py @@ -8,7 +8,10 @@ """ 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): @@ -27,6 +30,29 @@ class DeviceClient(Client): 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 diff --git a/tests/oauth2/rfc8628/clients/test_device.py b/tests/oauth2/rfc8628/clients/test_device.py index 597b3ab3..4428de61 100644 --- a/tests/oauth2/rfc8628/clients/test_device.py +++ b/tests/oauth2/rfc8628/clients/test_device.py @@ -16,13 +16,23 @@ class DeviceClientTest(TestCase): "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" - device_code = 'somedevicecode' + 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) @@ -39,3 +49,16 @@ def test_request_body(self): 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) From 7c0d474d2ce527e6f51f320002f92a4536eb3cc0 Mon Sep 17 00:00:00 2001 From: Michael Kelly Date: Wed, 22 Dec 2021 21:43:15 -0800 Subject: [PATCH 3/3] Remove encoding lines These lines are not required for python3 --- oauthlib/oauth2/rfc8628/clients/__init__.py | 1 - oauthlib/oauth2/rfc8628/clients/device.py | 1 - tests/oauth2/rfc8628/clients/test_device.py | 1 - 3 files changed, 3 deletions(-) diff --git a/oauthlib/oauth2/rfc8628/clients/__init__.py b/oauthlib/oauth2/rfc8628/clients/__init__.py index 38e4e618..130b52e3 100644 --- a/oauthlib/oauth2/rfc8628/clients/__init__.py +++ b/oauthlib/oauth2/rfc8628/clients/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ oauthlib.oauth2.rfc8628 ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py index f422b6bb..df7ff681 100644 --- a/oauthlib/oauth2/rfc8628/clients/device.py +++ b/oauthlib/oauth2/rfc8628/clients/device.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ oauthlib.oauth2.rfc8628 ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/oauth2/rfc8628/clients/test_device.py b/tests/oauth2/rfc8628/clients/test_device.py index 4428de61..725dea2a 100644 --- a/tests/oauth2/rfc8628/clients/test_device.py +++ b/tests/oauth2/rfc8628/clients/test_device.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import os from unittest.mock import patch