diff options
-rw-r--r-- | .travis.yml | 17 | ||||
-rw-r--r-- | AUTHORS | 19 | ||||
-rw-r--r-- | CHANGELOG.rst | 27 | ||||
-rw-r--r-- | docs/feature_matrix.rst | 21 | ||||
-rw-r--r-- | docs/oauth2/clients/deviceclient.rst | 5 | ||||
-rw-r--r-- | oauthlib/__init__.py | 2 | ||||
-rw-r--r-- | oauthlib/oauth2/__init__.py | 1 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc6749/grant_types/authorization_code.py | 18 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc6749/grant_types/base.py | 18 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc6749/grant_types/refresh_token.py | 1 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc6749/request_validator.py | 1 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc6749/tokens.py | 1 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc8628/__init__.py | 10 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc8628/clients/__init__.py | 8 | ||||
-rw-r--r-- | oauthlib/oauth2/rfc8628/clients/device.py | 94 | ||||
-rw-r--r-- | tests/oauth2/rfc6749/grant_types/test_refresh_token.py | 41 | ||||
-rw-r--r-- | tests/oauth2/rfc8628/__init__.py | 0 | ||||
-rw-r--r-- | tests/oauth2/rfc8628/clients/__init__.py | 0 | ||||
-rw-r--r-- | tests/oauth2/rfc8628/clients/test_device.py | 63 | ||||
-rw-r--r-- | tox.ini | 6 |
20 files changed, 310 insertions, 43 deletions
diff --git a/.travis.yml b/.travis.yml index 89af15d..b2dad7a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,21 +1,26 @@ language: python python: 3.8 +os: linux dist: bionic cache: pip -matrix: +jobs: include: - python: "3.6" env: TOXENV=py36 - python: "3.7" - env: TOXENV=py37 + env: TOXENV=py37,docs - python: "3.8" - env: TOXENV=py38,bandit,docs,readme + env: TOXENV=py38,bandit,readme - python: "3.9" env: TOXENV=py39 - - python: "3.10-dev" + - python: "3.10.2" env: TOXENV=py310 + - python: "3.11-dev" + env: TOXENV=py311 - python: "pypy3" env: TOXENV=pypy3 + allow_failures: + - python: "3.11-dev" before_install: - python -m pip install --upgrade pip setuptools - python -m pip install tox coveralls @@ -32,7 +37,7 @@ notifications: on_start: never deploy: - provider: releases - api_key: + token: secure: "eqEWOzKWZCuvd1a77CA03OX/HCrsYlsu1/Sz/RhXQIEhKz6tKp10KGw9zr57bHAIl0OfJFK9k63lI2HOctAmwkKeeQ4HdNqw4pHFa8Gk3liGp31KSmshVtHX8Rtn0DuFA028Wm7w5n+fOVc8tJVU/UsKjsfsAzRHnQjMamckoXU=" skip_cleanup: true on: @@ -41,7 +46,7 @@ deploy: condition: $TOXENV = py36 repo: oauthlib/oauthlib - provider: pypi - user: JonathanHuot + username: JonathanHuot password: secure: "OozNM16flVLvqDoNzmoTENchhS1w0/dEJZvXBQK2KWmh8fyGj2UZus1vkl6bA5V3Yu9MZLYFpDcltl/qraY3Up6iXQpwKz4q+ICygAudYM2kJ5l8ZEe+wy2FikWbD6LkXf5uKIJJnPNSC8AI86ZyxM/XZxbYjj/+jXyJ1YFZwwQ=" distributions: sdist bdist_wheel @@ -30,3 +30,22 @@ Jonathan Huot Pieter Ennes Olaf Conradi Tom Evans +Bella Woo +Alan Crosswell +Nikos Sklikas +Paul Dekkers +Jason com4 +Aman Singh Solanki +uy-rrodriguez +Sylvain MariƩ +Hoylen Sue +Christian Clauss +Mike Kelly +Xpyder +Theron Luhn +Alexander Freeman1981 +Jon Velando +Scott Gifford +Hugo van Kemenade +Richard Connon +Karim Kanso diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 21c9159..d7882e9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,15 +1,34 @@ Changelog ========= +3.2.0 (2022-01-29) +------------------ +OAuth2.0 Client: +* #795: Add Device Authorization Flow for Web Application +* #786: Add PKCE support for Client +* #783: Fallback to none in case of wrong expires_at format. + +OAuth2.0 Provider: +* #790: Add support for CORS to metadata endpoint. +* #791: Add support for CORS to token endpoint. +* #787: Remove comma after Bearer in WWW-Authenticate + +OAuth2.0 Provider - OIDC: + * #755: Call save_token in Hybrid code flow + * #751: OIDC add support of refreshing ID Tokens with `refresh_id_token` + * #751: The RefreshTokenGrant modifiers now take the same arguments as the + AuthorizationCodeGrant modifiers (`token`, `token_handler`, `request`). + +General: + * Added Python 3.9, 3.10, 3.11 + * Improve Travis & Coverage + 3.1.1 (2021-05-31) ------------------ OAuth2.0 Provider - Bugfixes * #753: Fix acceptance of valid IPv6 addresses in URI validation -OAuth2.0 Provider - Features - * #751: OIDC add support of refreshing ID Tokens - OAuth2.0 Client - Bugfixes * #730: Base OAuth2 Client now has a consistent way of managing the `scope`: it consistently @@ -28,8 +47,6 @@ OAuth2.0 Provider - Bugfixes * #746: OpenID Connect Hybrid - fix nonce not passed to add_id_token * #756: Different prompt values are now handled according to spec (e.g. prompt=none) * #759: OpenID Connect - fix Authorization: Basic parsing - * #751: The RefreshTokenGrant modifiers now take the same arguments as the - AuthorizationCodeGrant modifiers (`token`, `token_handler`, `request`). General * #716: improved skeleton validator for public vs private client diff --git a/docs/feature_matrix.rst b/docs/feature_matrix.rst index 56d0cf3..f9309f9 100644 --- a/docs/feature_matrix.rst +++ b/docs/feature_matrix.rst @@ -1,8 +1,8 @@ -Supported features and platforms -================================ +Features and platforms +====================== -Features --------- +.. contents:: + :local: OAuth 1.0a .......... @@ -39,16 +39,16 @@ OAuth 2.0 client and provider support for: - `RFC 6749 section-6`_: Refresh Tokens - `RFC 6750`_: Bearer Tokens - `RFC 7009`_: Token Revocation +- `RFC 7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) +- `RFC 8628`_: OAuth2.0 Device Authorization Grant - `RFC Draft`_ Message Authentication Code (MAC) Tokens + +Partial implementations (any help/PR are welcomed to complete the list): + - OAuth2.0 Provider: `OpenID Connect Core`_ -- OAuth2.0 Provider: `RFC 7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) - OAuth2.0 Provider: `RFC 7662`_: Token Introspection - OAuth2.0 Provider: `RFC 8414`_: Authorization Server Metadata - -Features to be implemented (any help/PR are welcomed): - - OAuth2.0 **Client**: `OpenID Connect Core`_ -- OAuth2.0 **Client**: `RFC 7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE) - OAuth2.0 **Client**: `RFC 7662`_: Token Introspection - OAuth2.0 **Client**: `RFC 8414`_: Authorization Server Metadata - SAML2 @@ -59,7 +59,7 @@ Features to be implemented (any help/PR are welcomed): - ...and more Platforms ---------- +......... OAuthLib is mainly developed and tested on 64-bit Linux. It works on Unix and Unix-like operating systems (including macOS), as well as @@ -85,5 +85,6 @@ additional packages: see the installation instructions for details. .. _`RFC 7009`: https://tools.ietf.org/html/rfc7009 .. _`RFC 7662`: https://tools.ietf.org/html/rfc7662 .. _`RFC 7636`: https://tools.ietf.org/html/rfc7636 +.. _`RFC 8628`: https://tools.ietf.org/html/rfc8628 .. _`OpenID Connect Core`: https://openid.net/specs/openid-connect-core-1_0.html .. _`RFC 8414`: https://tools.ietf.org/html/rfc8414 diff --git a/docs/oauth2/clients/deviceclient.rst b/docs/oauth2/clients/deviceclient.rst new file mode 100644 index 0000000..d4e8d7d --- /dev/null +++ b/docs/oauth2/clients/deviceclient.rst @@ -0,0 +1,5 @@ +DeviceClient +------------------------ + +.. autoclass:: oauthlib.oauth2.DeviceClient + :members: diff --git a/oauthlib/__init__.py b/oauthlib/__init__.py index a94cf94..5dbffc9 100644 --- a/oauthlib/__init__.py +++ b/oauthlib/__init__.py @@ -12,7 +12,7 @@ import logging from logging import NullHandler __author__ = 'The OAuthlib Community' -__version__ = '3.1.1' +__version__ = '3.2.0' logging.getLogger('oauthlib').addHandler(NullHandler()) diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index a6e1ccc..deefb1a 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -33,3 +33,4 @@ from .rfc6749.grant_types import ( 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/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py index b799823..858855a 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py +++ b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -10,7 +10,6 @@ import logging from oauthlib import common from .. import errors -from ..utils import is_secure_transport from .base import GrantTypeBase log = logging.getLogger(__name__) @@ -547,20 +546,3 @@ class AuthorizationCodeGrant(GrantTypeBase): if challenge_method in self._code_challenge_methods: return self._code_challenge_methods[challenge_method](verifier, challenge) raise NotImplementedError('Unknown challenge_method %s' % challenge_method) - - def _create_cors_headers(self, request): - """If CORS is allowed, create the appropriate headers.""" - if 'origin' not in request.headers: - return {} - - origin = request.headers['origin'] - if not is_secure_transport(origin): - log.debug('Origin "%s" is not HTTPS, CORS not allowed.', origin) - return {} - elif not self.request_validator.is_origin_allowed( - request.client_id, origin, request): - log.debug('Invalid origin "%s", CORS not allowed.', origin) - return {} - else: - log.debug('Valid origin "%s", injecting CORS headers.', origin) - return {'Access-Control-Allow-Origin': origin} diff --git a/oauthlib/oauth2/rfc6749/grant_types/base.py b/oauthlib/oauth2/rfc6749/grant_types/base.py index a64f168..ca343a1 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/base.py +++ b/oauthlib/oauth2/rfc6749/grant_types/base.py @@ -10,6 +10,7 @@ from oauthlib.oauth2.rfc6749 import errors, utils from oauthlib.uri_validate import is_absolute_uri from ..request_validator import RequestValidator +from ..utils import is_secure_transport log = logging.getLogger(__name__) @@ -248,3 +249,20 @@ class GrantTypeBase: raise errors.MissingRedirectURIError(request=request) if not is_absolute_uri(request.redirect_uri): raise errors.InvalidRedirectURIError(request=request) + + def _create_cors_headers(self, request): + """If CORS is allowed, create the appropriate headers.""" + if 'origin' not in request.headers: + return {} + + origin = request.headers['origin'] + if not is_secure_transport(origin): + log.debug('Origin "%s" is not HTTPS, CORS not allowed.', origin) + return {} + elif not self.request_validator.is_origin_allowed( + request.client_id, origin, request): + log.debug('Invalid origin "%s", CORS not allowed.', origin) + return {} + else: + log.debug('Valid origin "%s", injecting CORS headers.', origin) + return {'Access-Control-Allow-Origin': origin} diff --git a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py index f801de4..ce33df0 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py +++ b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -69,6 +69,7 @@ class RefreshTokenGrant(GrantTypeBase): log.debug('Issuing new token to client id %r (%r), %r.', request.client_id, request.client, token) + headers.update(self._create_cors_headers(request)) return headers, json.dumps(token), 200 def validate_token_request(self, request): diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py index 610a708..02a13fa 100644 --- a/oauthlib/oauth2/rfc6749/request_validator.py +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -671,6 +671,7 @@ class RequestValidator: Method is used by: - Authorization Code Grant + - Refresh Token Grant """ return False diff --git a/oauthlib/oauth2/rfc6749/tokens.py b/oauthlib/oauth2/rfc6749/tokens.py index 6284248..0757d07 100644 --- a/oauthlib/oauth2/rfc6749/tokens.py +++ b/oauthlib/oauth2/rfc6749/tokens.py @@ -257,6 +257,7 @@ def get_token_from_header(request): class TokenBase: + __slots__ = () def __call__(self, request, refresh_token=False): raise NotImplementedError('Subclasses must implement this method.') diff --git a/oauthlib/oauth2/rfc8628/__init__.py b/oauthlib/oauth2/rfc8628/__init__.py new file mode 100644 index 0000000..531929d --- /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 0000000..130b52e --- /dev/null +++ b/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 diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py new file mode 100644 index 0000000..95c4f5a --- /dev/null +++ b/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 DeviceClient + >>> 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/rfc6749/grant_types/test_refresh_token.py b/tests/oauth2/rfc6749/grant_types/test_refresh_token.py index 1d3e77a..581f2a4 100644 --- a/tests/oauth2/rfc6749/grant_types/test_refresh_token.py +++ b/tests/oauth2/rfc6749/grant_types/test_refresh_token.py @@ -18,6 +18,7 @@ class RefreshTokenGrantTest(TestCase): self.request = Request('http://a.b/path') self.request.grant_type = 'refresh_token' self.request.refresh_token = 'lsdkfhj230' + self.request.client_id = 'abcdef' self.request.client = mock_client self.request.scope = 'foo' self.mock_validator = mock.MagicMock() @@ -168,3 +169,43 @@ class RefreshTokenGrantTest(TestCase): del self.request.scope self.auth.validate_token_request(self.request) self.assertEqual(self.request.scopes, 'foo bar baz'.split()) + + # CORS + + def test_create_cors_headers(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = True + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertEqual( + headers['Access-Control-Allow-Origin'], 'https://foo.bar' + ) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) + + def test_create_cors_headers_no_origin(self): + bearer = BearerToken(self.mock_validator) + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_insecure_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'http://foo.bar' + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_invalid_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = False + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) diff --git a/tests/oauth2/rfc8628/__init__.py b/tests/oauth2/rfc8628/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/oauth2/rfc8628/__init__.py diff --git a/tests/oauth2/rfc8628/clients/__init__.py b/tests/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/oauth2/rfc8628/clients/__init__.py diff --git a/tests/oauth2/rfc8628/clients/test_device.py b/tests/oauth2/rfc8628/clients/test_device.py new file mode 100644 index 0000000..725dea2 --- /dev/null +++ b/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) @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37,py38,py39,py310,pypy3,docs,readme,bandit,isort +envlist = py36,py37,py38,py39,py310,py311,pypy3,docs,readme,bandit,isort [testenv] deps= @@ -9,9 +9,9 @@ commands= # tox -e docs to mimick readthedocs build. -# as of today, RTD is using python3.6 and doesn't run "setup.py install" +# as of today, RTD is using python3.7 and doesn't run "setup.py install" [testenv:docs] -basepython=python3.6 +basepython=python3.7 skipsdist=True deps= sphinx |