diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 82dbd75..2b2b6d5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,36 @@ Changelog ========= +3.3.1 (2025-06-19): +------------------ +OAuth2.0 Client: +* #906: fix regression of expires_in parsing when float in string. + + +3.3.0 (2025-06-17): +------------------ +OAuth2.0 Provider: +* OIDC: #879 Changed in how ui_locales is parsed +* RFC8628: Added OAuth2.0 Device Authorization Grant support +* PKCE: #876, #893 Fixed `create_code_verifier` length +* OIDC: Pre-configured OIDC server to use Refresh Token by default + +OAuth2.0 Common: +* OAuth2Error: Allow 0 to be a valid state + +OAuth2.0 Client: +* #745: expires_at is forced to be an int +* #899: expires_at clarification + +General: +* Removed Python 3.5, 3.6, 3.7 support +* #859, #883: Added Python 3.12, 3.13 Support +* Added dependency-review GitHub Action +* Updated various references of license (SPDX identifier..) +* Added GitHub Action for lint, replaced bandy with ruff, removed isort... +* Migrated to GitHub Actions from Travis +* Added Security Policy + 3.2.2 (2022-10-17) ------------------ OAuth2.0 Provider: diff --git a/LICENSE b/LICENSE index d5a9e9a..ffab126 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019 The OAuthlib Community +Copyright (c) The OAuthlib Community All rights reserved. Redistribution and use in source and binary forms, with or without @@ -11,14 +11,14 @@ modification, are permitted provided that the following conditions are met: notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - 3. Neither the name of this project nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER diff --git a/PKG-INFO b/PKG-INFO index 4278877..83e56a4 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,50 +1,65 @@ -Metadata-Version: 2.1 +Metadata-Version: 2.4 Name: oauthlib -Version: 3.2.2 +Version: 3.3.1 Summary: A generic, spec-compliant, thorough implementation of the OAuth request-signing logic Home-page: https://github.com/oauthlib/oauthlib Author: The OAuthlib Community -Author-email: idan@gazit.me -Maintainer: Ib Lundgren -Maintainer-email: ib.lundgren@gmail.com -License: BSD +Maintainer: Jonathan Huot +Maintainer-email: jonathan.huot@gmail.com +License: BSD-3-Clause Platform: any Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved -Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: MacOS Classifier: Operating System :: POSIX Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.6 -Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: Implementation Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Software Development :: Libraries :: Python Modules -Requires-Python: >=3.6 +Requires-Python: >=3.8 Description-Content-Type: text/x-rst -Provides-Extra: rsa -Provides-Extra: signedtoken -Provides-Extra: signals License-File: LICENSE +Provides-Extra: rsa +Requires-Dist: cryptography>=3.0.0; extra == "rsa" +Provides-Extra: signedtoken +Requires-Dist: cryptography>=3.0.0; extra == "signedtoken" +Requires-Dist: pyjwt<3,>=2.0.0; extra == "signedtoken" +Provides-Extra: signals +Requires-Dist: blinker>=1.4.0; extra == "signals" +Dynamic: author +Dynamic: classifier +Dynamic: description +Dynamic: description-content-type +Dynamic: home-page +Dynamic: license +Dynamic: license-file +Dynamic: maintainer +Dynamic: maintainer-email +Dynamic: platform +Dynamic: provides-extra +Dynamic: requires-python +Dynamic: summary OAuthLib - Python Framework for OAuth1 & OAuth2 =============================================== *A generic, spec-compliant, thorough implementation of the OAuth request-signing -logic for Python 3.6+.* +logic for Python 3.8+* -.. image:: https://app.travis-ci.com/oauthlib/oauthlib.svg?branch=master - :target: https://app.travis-ci.com/oauthlib/oauthlib - :alt: Travis +.. image:: https://github.com/oauthlib/oauthlib/actions/workflows/python-build.yml/badge.svg + :target: https://github.com/oauthlib/oauthlib/actions + :alt: GitHub Actions .. image:: https://coveralls.io/repos/oauthlib/oauthlib/badge.svg?branch=master :target: https://coveralls.io/r/oauthlib/oauthlib :alt: Coveralls @@ -113,7 +128,9 @@ Which web frameworks are supported? The following packages provide OAuth support using OAuthLib. -- For Django there is `django-oauth-toolkit`_, which includes `Django REST framework`_ support. +- For Django there is: + - `django-oauth-toolkit`_, which includes `Django REST framework`_ support. + - `django-allauth`_, which includes `Django REST framework`_ as well as `Django Ninja`_ support. - For Flask there is `flask-oauthlib`_ and `Flask-Dance`_. - For Pyramid there is `pyramid-oauthlib`_. - For Bottle there is `bottle-oauthlib`_. @@ -127,6 +144,8 @@ please open a Pull Request, updating the documentation. .. _`Flask-Dance`: https://github.com/singingwolfboy/flask-dance .. _`pyramid-oauthlib`: https://github.com/tilgovi/pyramid-oauthlib .. _`bottle-oauthlib`: https://github.com/thomsonreuters/bottle-oauthlib +.. _`django-allauth`: https://allauth.org/ +.. _`Django Ninja`: https://django-ninja.dev/ Using OAuthLib? Please get in touch! ------------------------------------ @@ -148,7 +167,7 @@ have the pleasure to run into each other, please send a docs pull request =) License ------- -OAuthLib is yours to use and abuse according to the terms of the BSD license. +OAuthLib is yours to use and abuse according to the terms of the BSD-3-Clause license. Check the LICENSE file for full details. Credits diff --git a/README.rst b/README.rst index eb8c452..840c33d 100644 --- a/README.rst +++ b/README.rst @@ -2,11 +2,11 @@ OAuthLib - Python Framework for OAuth1 & OAuth2 =============================================== *A generic, spec-compliant, thorough implementation of the OAuth request-signing -logic for Python 3.6+.* +logic for Python 3.8+* -.. image:: https://app.travis-ci.com/oauthlib/oauthlib.svg?branch=master - :target: https://app.travis-ci.com/oauthlib/oauthlib - :alt: Travis +.. image:: https://github.com/oauthlib/oauthlib/actions/workflows/python-build.yml/badge.svg + :target: https://github.com/oauthlib/oauthlib/actions + :alt: GitHub Actions .. image:: https://coveralls.io/repos/oauthlib/oauthlib/badge.svg?branch=master :target: https://coveralls.io/r/oauthlib/oauthlib :alt: Coveralls @@ -75,7 +75,9 @@ Which web frameworks are supported? The following packages provide OAuth support using OAuthLib. -- For Django there is `django-oauth-toolkit`_, which includes `Django REST framework`_ support. +- For Django there is: + - `django-oauth-toolkit`_, which includes `Django REST framework`_ support. + - `django-allauth`_, which includes `Django REST framework`_ as well as `Django Ninja`_ support. - For Flask there is `flask-oauthlib`_ and `Flask-Dance`_. - For Pyramid there is `pyramid-oauthlib`_. - For Bottle there is `bottle-oauthlib`_. @@ -89,6 +91,8 @@ please open a Pull Request, updating the documentation. .. _`Flask-Dance`: https://github.com/singingwolfboy/flask-dance .. _`pyramid-oauthlib`: https://github.com/tilgovi/pyramid-oauthlib .. _`bottle-oauthlib`: https://github.com/thomsonreuters/bottle-oauthlib +.. _`django-allauth`: https://allauth.org/ +.. _`Django Ninja`: https://django-ninja.dev/ Using OAuthLib? Please get in touch! ------------------------------------ @@ -110,7 +114,7 @@ have the pleasure to run into each other, please send a docs pull request =) License ------- -OAuthLib is yours to use and abuse according to the terms of the BSD license. +OAuthLib is yours to use and abuse according to the terms of the BSD-3-Clause license. Check the LICENSE file for full details. Credits diff --git a/debian/changelog b/debian/changelog index e5e01db..cb1bb10 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +python-oauthlib (3.3.1-ok1) nile; urgency=medium + + Initial release for 3.3.1 + + -- tangtingting Wed, 02 Jul 2025 17:48:02 +0800 + python-oauthlib (3.2.2-ok1) nile; urgency=medium * Build for openKylin. diff --git a/oauthlib.egg-info/PKG-INFO b/oauthlib.egg-info/PKG-INFO index 4278877..83e56a4 100644 --- a/oauthlib.egg-info/PKG-INFO +++ b/oauthlib.egg-info/PKG-INFO @@ -1,50 +1,65 @@ -Metadata-Version: 2.1 +Metadata-Version: 2.4 Name: oauthlib -Version: 3.2.2 +Version: 3.3.1 Summary: A generic, spec-compliant, thorough implementation of the OAuth request-signing logic Home-page: https://github.com/oauthlib/oauthlib Author: The OAuthlib Community -Author-email: idan@gazit.me -Maintainer: Ib Lundgren -Maintainer-email: ib.lundgren@gmail.com -License: BSD +Maintainer: Jonathan Huot +Maintainer-email: jonathan.huot@gmail.com +License: BSD-3-Clause Platform: any Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers -Classifier: License :: OSI Approved -Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: MacOS Classifier: Operating System :: POSIX Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.6 -Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: Implementation Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Software Development :: Libraries :: Python Modules -Requires-Python: >=3.6 +Requires-Python: >=3.8 Description-Content-Type: text/x-rst -Provides-Extra: rsa -Provides-Extra: signedtoken -Provides-Extra: signals License-File: LICENSE +Provides-Extra: rsa +Requires-Dist: cryptography>=3.0.0; extra == "rsa" +Provides-Extra: signedtoken +Requires-Dist: cryptography>=3.0.0; extra == "signedtoken" +Requires-Dist: pyjwt<3,>=2.0.0; extra == "signedtoken" +Provides-Extra: signals +Requires-Dist: blinker>=1.4.0; extra == "signals" +Dynamic: author +Dynamic: classifier +Dynamic: description +Dynamic: description-content-type +Dynamic: home-page +Dynamic: license +Dynamic: license-file +Dynamic: maintainer +Dynamic: maintainer-email +Dynamic: platform +Dynamic: provides-extra +Dynamic: requires-python +Dynamic: summary OAuthLib - Python Framework for OAuth1 & OAuth2 =============================================== *A generic, spec-compliant, thorough implementation of the OAuth request-signing -logic for Python 3.6+.* +logic for Python 3.8+* -.. image:: https://app.travis-ci.com/oauthlib/oauthlib.svg?branch=master - :target: https://app.travis-ci.com/oauthlib/oauthlib - :alt: Travis +.. image:: https://github.com/oauthlib/oauthlib/actions/workflows/python-build.yml/badge.svg + :target: https://github.com/oauthlib/oauthlib/actions + :alt: GitHub Actions .. image:: https://coveralls.io/repos/oauthlib/oauthlib/badge.svg?branch=master :target: https://coveralls.io/r/oauthlib/oauthlib :alt: Coveralls @@ -113,7 +128,9 @@ Which web frameworks are supported? The following packages provide OAuth support using OAuthLib. -- For Django there is `django-oauth-toolkit`_, which includes `Django REST framework`_ support. +- For Django there is: + - `django-oauth-toolkit`_, which includes `Django REST framework`_ support. + - `django-allauth`_, which includes `Django REST framework`_ as well as `Django Ninja`_ support. - For Flask there is `flask-oauthlib`_ and `Flask-Dance`_. - For Pyramid there is `pyramid-oauthlib`_. - For Bottle there is `bottle-oauthlib`_. @@ -127,6 +144,8 @@ please open a Pull Request, updating the documentation. .. _`Flask-Dance`: https://github.com/singingwolfboy/flask-dance .. _`pyramid-oauthlib`: https://github.com/tilgovi/pyramid-oauthlib .. _`bottle-oauthlib`: https://github.com/thomsonreuters/bottle-oauthlib +.. _`django-allauth`: https://allauth.org/ +.. _`Django Ninja`: https://django-ninja.dev/ Using OAuthLib? Please get in touch! ------------------------------------ @@ -148,7 +167,7 @@ have the pleasure to run into each other, please send a docs pull request =) License ------- -OAuthLib is yours to use and abuse according to the terms of the BSD license. +OAuthLib is yours to use and abuse according to the terms of the BSD-3-Clause license. Check the LICENSE file for full details. Credits diff --git a/oauthlib.egg-info/SOURCES.txt b/oauthlib.egg-info/SOURCES.txt index 6b664b6..4ee3c35 100644 --- a/oauthlib.egg-info/SOURCES.txt +++ b/oauthlib.egg-info/SOURCES.txt @@ -59,8 +59,15 @@ oauthlib/oauth2/rfc6749/grant_types/implicit.py oauthlib/oauth2/rfc6749/grant_types/refresh_token.py oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py oauthlib/oauth2/rfc8628/__init__.py +oauthlib/oauth2/rfc8628/errors.py +oauthlib/oauth2/rfc8628/request_validator.py oauthlib/oauth2/rfc8628/clients/__init__.py oauthlib/oauth2/rfc8628/clients/device.py +oauthlib/oauth2/rfc8628/endpoints/__init__.py +oauthlib/oauth2/rfc8628/endpoints/device_authorization.py +oauthlib/oauth2/rfc8628/endpoints/pre_configured.py +oauthlib/oauth2/rfc8628/grant_types/__init__.py +oauthlib/oauth2/rfc8628/grant_types/device_code.py oauthlib/openid/__init__.py oauthlib/openid/connect/__init__.py oauthlib/openid/connect/core/__init__.py @@ -127,8 +134,14 @@ tests/oauth2/rfc6749/grant_types/test_implicit.py tests/oauth2/rfc6749/grant_types/test_refresh_token.py tests/oauth2/rfc6749/grant_types/test_resource_owner_password.py tests/oauth2/rfc8628/__init__.py +tests/oauth2/rfc8628/test_server.py tests/oauth2/rfc8628/clients/__init__.py tests/oauth2/rfc8628/clients/test_device.py +tests/oauth2/rfc8628/endpoints/__init__.py +tests/oauth2/rfc8628/endpoints/test_device_application_server.py +tests/oauth2/rfc8628/endpoints/test_error_responses.py +tests/oauth2/rfc8628/grant_types/__init__.py +tests/oauth2/rfc8628/grant_types/test_device_code.py tests/openid/__init__.py tests/openid/connect/__init__.py tests/openid/connect/core/__init__.py @@ -138,6 +151,7 @@ tests/openid/connect/core/test_tokens.py tests/openid/connect/core/endpoints/__init__.py tests/openid/connect/core/endpoints/test_claims_handling.py tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py +tests/openid/connect/core/endpoints/test_refresh_token.py tests/openid/connect/core/endpoints/test_userinfo_endpoint.py tests/openid/connect/core/grant_types/__init__.py tests/openid/connect/core/grant_types/test_authorization_code.py diff --git a/oauthlib/__init__.py b/oauthlib/__init__.py index d9a5e38..462612f 100644 --- a/oauthlib/__init__.py +++ b/oauthlib/__init__.py @@ -5,30 +5,30 @@ A generic, spec-compliant, thorough implementation of the OAuth request-signing logic. - :copyright: (c) 2019 by The OAuthlib Community - :license: BSD, see LICENSE for details. + :copyright: (c) The OAuthlib Community + :license: BSD-3-Clause, see LICENSE for details. """ import logging from logging import NullHandler __author__ = 'The OAuthlib Community' -__version__ = '3.2.2' +__version__ = '3.3.1' logging.getLogger('oauthlib').addHandler(NullHandler()) _DEBUG = False def set_debug(debug_val): - """Set value of debug flag - + """Set value of debug flag + :param debug_val: Value to set. Must be a bool value. - """ - global _DEBUG - _DEBUG = debug_val + """ + global _DEBUG # noqa: PLW0603 + _DEBUG = debug_val def get_debug(): - """Get debug mode value. - - :return: `True` if debug mode is on, `False` otherwise - """ - return _DEBUG + """Get debug mode value. + + :return: `True` if debug mode is on, `False` otherwise + """ + return _DEBUG diff --git a/oauthlib/common.py b/oauthlib/common.py index 395e75e..dfa8517 100644 --- a/oauthlib/common.py +++ b/oauthlib/common.py @@ -34,7 +34,7 @@ INVALID_HEX_PATTERN = re.compile(r'%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]') always_safe = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz' - '0123456789' '_.-') + '0123456789_.-') log = logging.getLogger('oauthlib') @@ -198,7 +198,7 @@ def generate_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET): def generate_signed_token(private_pem, request): - import jwt + import jwt # noqa: PLC0415 now = datetime.datetime.utcnow() @@ -216,7 +216,7 @@ def generate_signed_token(private_pem, request): def verify_signed_token(public_pem, token): - import jwt + import jwt # noqa: PLC0415 return jwt.decode(token, public_pem, algorithms=['RS256']) @@ -316,7 +316,7 @@ class CaseInsensitiveDict(dict): return super().__getitem__(key) def get(self, k, default=None): - return self[k] if k in self else default + return self[k] if k in self else default # noqa: SIM401 def __setitem__(self, k, v): super().__setitem__(k, v) @@ -346,7 +346,8 @@ class Request: def __init__(self, uri, http_method='GET', body=None, headers=None, encoding='utf-8'): # Convert to unicode using encoding if given, else assume unicode - encode = lambda x: to_unicode(x, encoding) if encoding else x + def encode(x): + return to_unicode(x, encoding) if encoding else x self.uri = encode(uri) self.http_method = encode(http_method) diff --git a/oauthlib/oauth1/rfc5849/__init__.py b/oauthlib/oauth1/rfc5849/__init__.py index c559251..85e0b90 100644 --- a/oauthlib/oauth1/rfc5849/__init__.py +++ b/oauthlib/oauth1/rfc5849/__init__.py @@ -121,7 +121,8 @@ class Client: :param timestamp: Use this timestamp instead of using current. (Mainly for testing) """ # Convert to unicode using encoding if given, else assume unicode - encode = lambda x: to_unicode(x, encoding) if encoding else x + def encode(x): + return to_unicode(x, encoding) if encoding else x self.client_key = encode(client_key) self.client_secret = encode(client_secret) @@ -219,7 +220,7 @@ class Client: content_type = request.headers.get('Content-Type', None) content_type_eligible = content_type and content_type.find('application/x-www-form-urlencoded') < 0 if request.body is not None and content_type_eligible: - params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1(request.body.encode('utf-8')).digest()).decode('utf-8'))) + params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1(request.body.encode('utf-8')).digest()).decode('utf-8'))) # noqa: S324 return params diff --git a/oauthlib/oauth1/rfc5849/endpoints/base.py b/oauthlib/oauth1/rfc5849/endpoints/base.py index 7831be7..8d3d89c 100644 --- a/oauthlib/oauth1/rfc5849/endpoints/base.py +++ b/oauthlib/oauth1/rfc5849/endpoints/base.py @@ -69,12 +69,10 @@ class BaseEndpoint: def _create_request(self, uri, http_method, body, headers): # Only include body data from x-www-form-urlencoded requests headers = CaseInsensitiveDict(headers or {}) - if ("Content-Type" in headers and - CONTENT_TYPE_FORM_URLENCODED in headers["Content-Type"]): + if "Content-Type" in headers and CONTENT_TYPE_FORM_URLENCODED in headers["Content-Type"]: # noqa: SIM108 request = Request(uri, http_method, body, headers) else: request = Request(uri, http_method, '', headers) - signature_type, params, oauth_params = ( self._get_signature_type_and_params(request)) @@ -129,8 +127,7 @@ class BaseEndpoint: # Considerations section (`Section 4`_) before deciding on which # method to support. # .. _`Section 4`: https://tools.ietf.org/html/rfc5849#section-4 - if (not request.signature_method in - self.request_validator.allowed_signature_methods): + if (request.signature_method not in self.request_validator.allowed_signature_methods): raise errors.InvalidSignatureMethodError( description="Invalid signature, {} not in {!r}.".format( request.signature_method, @@ -180,9 +177,7 @@ class BaseEndpoint: def _check_signature(self, request, is_token_request=False): # ---- RSA Signature verification ---- - if request.signature_method == SIGNATURE_RSA_SHA1 or \ - request.signature_method == SIGNATURE_RSA_SHA256 or \ - request.signature_method == SIGNATURE_RSA_SHA512: + if request.signature_method in {SIGNATURE_RSA_SHA1, SIGNATURE_RSA_SHA256, SIGNATURE_RSA_SHA512}: # RSA-based signature method # The server verifies the signature per `[RFC3447] section 8.2.2`_ diff --git a/oauthlib/oauth1/rfc5849/signature.py b/oauthlib/oauth1/rfc5849/signature.py index 9cb1a51..a27cb2e 100644 --- a/oauthlib/oauth1/rfc5849/signature.py +++ b/oauthlib/oauth1/rfc5849/signature.py @@ -45,6 +45,7 @@ import warnings from oauthlib.common import extract_params, safe_string_equals, urldecode from . import utils +import contextlib log = logging.getLogger(__name__) @@ -188,10 +189,9 @@ def base_string_uri(uri: str, host: str = None) -> str: raise ValueError('missing host') # NOTE: Try guessing if we're dealing with IP or hostname - try: + with contextlib.suppress(ValueError): hostname = ipaddress.ip_address(hostname) - except ValueError: - pass + if isinstance(hostname, ipaddress.IPv6Address): hostname = f"[{hostname}]" @@ -568,7 +568,7 @@ def _get_jwt_rsa_algorithm(hash_algorithm_name: str): # Not in cache: instantiate a new RSAAlgorithm # PyJWT has some nice pycrypto/cryptography abstractions - import jwt.algorithms as jwt_algorithms + import jwt.algorithms as jwt_algorithms # noqa: PLC0415 m = { 'SHA-1': jwt_algorithms.hashes.SHA1, 'SHA-256': jwt_algorithms.hashes.SHA256, diff --git a/oauthlib/oauth1/rfc5849/utils.py b/oauthlib/oauth1/rfc5849/utils.py index 8fb8302..0915105 100644 --- a/oauthlib/oauth1/rfc5849/utils.py +++ b/oauthlib/oauth1/rfc5849/utils.py @@ -30,7 +30,8 @@ def filter_params(target): def filter_oauth_params(params): """Removes all non oauth parameters from a dict or a list of params.""" - is_oauth = lambda kv: kv[0].startswith("oauth_") + def is_oauth(kv): + return kv[0].startswith('oauth_') if isinstance(params, dict): return list(filter(is_oauth, list(params.items()))) else: @@ -59,7 +60,7 @@ def unescape(u): return unquote(u) -def parse_keqv_list(l): +def parse_keqv_list(l): # noqa: E741 """A unicode-safe version of urllib2.parse_keqv_list""" # With Python 2.6, parse_http_list handles unicode fine return urllib2.parse_keqv_list(l) diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index deefb1a..3bb5102 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -5,32 +5,66 @@ oauthlib.oauth2 This module is a wrapper for the most recent implementation of OAuth 2.0 Client and Server classes. """ + from .rfc6749.clients import ( - BackendApplicationClient, Client, LegacyApplicationClient, - MobileApplicationClient, ServiceApplicationClient, WebApplicationClient, + BackendApplicationClient, + Client, + LegacyApplicationClient, + MobileApplicationClient, + ServiceApplicationClient, + WebApplicationClient, ) from .rfc6749.endpoints import ( - AuthorizationEndpoint, BackendApplicationServer, IntrospectEndpoint, - LegacyApplicationServer, MetadataEndpoint, MobileApplicationServer, - ResourceEndpoint, RevocationEndpoint, Server, TokenEndpoint, + AuthorizationEndpoint, + BackendApplicationServer, + IntrospectEndpoint, + LegacyApplicationServer, + MetadataEndpoint, + MobileApplicationServer, + ResourceEndpoint, + RevocationEndpoint, + Server, + TokenEndpoint, WebApplicationServer, ) from .rfc6749.errors import ( - AccessDeniedError, FatalClientError, InsecureTransportError, - InvalidClientError, InvalidClientIdError, InvalidGrantError, - InvalidRedirectURIError, InvalidRequestError, InvalidRequestFatalError, - InvalidScopeError, MismatchingRedirectURIError, MismatchingStateError, - MissingClientIdError, MissingCodeError, MissingRedirectURIError, - MissingResponseTypeError, MissingTokenError, MissingTokenTypeError, - OAuth2Error, ServerError, TemporarilyUnavailableError, TokenExpiredError, - UnauthorizedClientError, UnsupportedGrantTypeError, - UnsupportedResponseTypeError, UnsupportedTokenTypeError, + AccessDeniedError, + FatalClientError, + InsecureTransportError, + InvalidClientError, + InvalidClientIdError, + InvalidGrantError, + InvalidRedirectURIError, + InvalidRequestError, + InvalidRequestFatalError, + InvalidScopeError, + MismatchingRedirectURIError, + MismatchingStateError, + MissingClientIdError, + MissingCodeError, + MissingRedirectURIError, + MissingResponseTypeError, + MissingTokenError, + MissingTokenTypeError, + OAuth2Error, + ServerError, + TemporarilyUnavailableError, + TokenExpiredError, + UnauthorizedClientError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, + UnsupportedTokenTypeError, ) from .rfc6749.grant_types import ( - AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + AuthorizationCodeGrant, + ClientCredentialsGrant, + ImplicitGrant, + RefreshTokenGrant, + ResourceOwnerPasswordCredentialsGrant, ) from .rfc6749.request_validator import RequestValidator from .rfc6749.tokens import BearerToken, OAuth2Token from .rfc6749.utils import is_secure_transport from .rfc8628.clients import DeviceClient +from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint, DeviceApplicationServer +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant diff --git a/oauthlib/oauth2/rfc6749/clients/base.py b/oauthlib/oauth2/rfc6749/clients/base.py index d5eb0cc..17f833d 100644 --- a/oauthlib/oauth2/rfc6749/clients/base.py +++ b/oauthlib/oauth2/rfc6749/clients/base.py @@ -8,17 +8,16 @@ for consuming OAuth 2.0 RFC6749. """ import base64 import hashlib -import re -import secrets import time import warnings -from oauthlib.common import generate_token +from oauthlib.common import UNICODE_ASCII_CHARACTER_SET, generate_token from oauthlib.oauth2.rfc6749 import tokens from oauthlib.oauth2.rfc6749.errors import ( InsecureTransportError, TokenExpiredError, ) from oauthlib.oauth2.rfc6749.parameters import ( + parse_expires, parse_token_response, prepare_token_request, prepare_token_revocation_request, ) @@ -207,7 +206,7 @@ class Client: case_insensitive_token_types = { k.lower(): v for k, v in self.token_types.items()} - if not self.token_type.lower() in case_insensitive_token_types: + if self.token_type.lower() not in case_insensitive_token_types: raise ValueError("Unsupported token type: %s" % self.token_type) if not (self.access_token or self.token.get('access_token')): @@ -466,7 +465,7 @@ class Client: return uri, headers, body def create_code_verifier(self, length): - """Create PKCE **code_verifier** used in computing **code_challenge**. + """Create PKCE **code_verifier** used in computing **code_challenge**. See `RFC7636 Section 4.1`_ :param length: REQUIRED. The length of the code_verifier. @@ -491,11 +490,7 @@ class Client: if not length <= 128: raise ValueError("Length must be less than or equal to 128") - allowed_characters = re.compile('^[A-Zaa-z0-9-._~]') - code_verifier = secrets.token_urlsafe(length) - - if not re.search(allowed_characters, code_verifier): - raise ValueError("code_verifier contains invalid characters") + code_verifier = generate_token(length, UNICODE_ASCII_CHARACTER_SET + "-._~") self.code_verifier = code_verifier @@ -530,10 +525,10 @@ class Client: """ code_challenge = None - if code_verifier == None: + if code_verifier is None: raise ValueError("Invalid code_verifier") - if code_challenge_method == None: + if code_challenge_method is None: code_challenge_method = "plain" self.code_challenge_method = code_challenge_method code_challenge = code_verifier @@ -587,15 +582,13 @@ class Client: if 'token_type' in response: self.token_type = response.get('token_type') - if 'expires_in' in response: - self.expires_in = response.get('expires_in') - self._expires_at = time.time() + int(self.expires_in) - - if 'expires_at' in response: - try: - self._expires_at = int(response.get('expires_at')) - except: - self._expires_at = None + vin, vat, v_at = parse_expires(response) + if vin: + self.expires_in = vin + if vat: + self.expires_at = vat + if v_at: + self._expires_at = v_at if 'mac_key' in response: self.mac_key = response.get('mac_key') diff --git a/oauthlib/oauth2/rfc6749/clients/mobile_application.py b/oauthlib/oauth2/rfc6749/clients/mobile_application.py index b10b41c..023cf23 100644 --- a/oauthlib/oauth2/rfc6749/clients/mobile_application.py +++ b/oauthlib/oauth2/rfc6749/clients/mobile_application.py @@ -43,7 +43,7 @@ class MobileApplicationClient(Client): redirection URI, it may be exposed to the resource owner and other applications residing on the same device. """ - + response_type = 'token' def prepare_request_uri(self, uri, redirect_uri=None, scope=None, diff --git a/oauthlib/oauth2/rfc6749/clients/service_application.py b/oauthlib/oauth2/rfc6749/clients/service_application.py index 8fb1737..abf22d2 100644 --- a/oauthlib/oauth2/rfc6749/clients/service_application.py +++ b/oauthlib/oauth2/rfc6749/clients/service_application.py @@ -91,7 +91,7 @@ class ServiceApplicationClient(Client): ``https://provider.com/oauth2/token``. :param expires_at: A unix expiration timestamp for the JWT. Defaults - to an hour from now, i.e. ``time.time() + 3600``. + to an hour from now, i.e. ``round(time.time()) + 3600``. :param issued_at: A unix timestamp of when the JWT was created. Defaults to now, i.e. ``time.time()``. @@ -149,7 +149,7 @@ class ServiceApplicationClient(Client): .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 """ - import jwt + import jwt # noqa: PLC0415 key = private_key or self.private_key if not key: diff --git a/oauthlib/oauth2/rfc6749/clients/web_application.py b/oauthlib/oauth2/rfc6749/clients/web_application.py index 50890fb..3bf94c4 100644 --- a/oauthlib/oauth2/rfc6749/clients/web_application.py +++ b/oauthlib/oauth2/rfc6749/clients/web_application.py @@ -33,7 +33,7 @@ class WebApplicationClient(Client): browser) and capable of receiving incoming requests (via redirection) from the authorization server. """ - + grant_type = 'authorization_code' def __init__(self, client_id, code=None, **kwargs): @@ -62,8 +62,8 @@ class WebApplicationClient(Client): to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in `Section 10.12`_. - :param code_challenge: OPTIONAL. PKCE parameter. REQUIRED if PKCE is enforced. - A challenge derived from the code_verifier that is sent in the + :param code_challenge: OPTIONAL. PKCE parameter. REQUIRED if PKCE is enforced. + A challenge derived from the code_verifier that is sent in the authorization request, to be verified against later. :param code_challenge_method: OPTIONAL. PKCE parameter. A method that was used to derive code challenge. diff --git a/oauthlib/oauth2/rfc6749/endpoints/base.py b/oauthlib/oauth2/rfc6749/endpoints/base.py index 3f23991..987fac6 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/base.py +++ b/oauthlib/oauth2/rfc6749/endpoints/base.py @@ -32,7 +32,7 @@ class BaseEndpoint: if valid_request_methods is not None: valid_request_methods = [x.upper() for x in valid_request_methods] self._valid_request_methods = valid_request_methods - + @property def available(self): @@ -40,7 +40,7 @@ class BaseEndpoint: @available.setter def available(self, available): - self._available = available + self._available = available @property def catch_errors(self): diff --git a/oauthlib/oauth2/rfc6749/endpoints/introspect.py b/oauthlib/oauth2/rfc6749/endpoints/introspect.py index 3cc61e6..ef73988 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/introspect.py +++ b/oauthlib/oauth2/rfc6749/endpoints/introspect.py @@ -74,7 +74,7 @@ class IntrospectEndpoint(BaseEndpoint): request ) if claims is None: - return resp_headers, json.dumps(dict(active=False)), 200 + return resp_headers, json.dumps({'active': False}), 200 if "active" in claims: claims.pop("active") return resp_headers, json.dumps(dict(active=True, **claims)), 200 diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index a2820f2..34274cb 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -38,9 +38,9 @@ class MetadataEndpoint(BaseEndpoint): """ def __init__(self, endpoints, claims={}, raise_errors=True): - assert isinstance(claims, dict) + assert isinstance(claims, dict) # noqa: S101 for endpoint in endpoints: - assert isinstance(endpoint, BaseEndpoint) + assert isinstance(endpoint, BaseEndpoint) # noqa: S101 BaseEndpoint.__init__(self) self.raise_errors = raise_errors diff --git a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py index d64a166..8f6aa32 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py +++ b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -5,9 +5,13 @@ oauthlib.oauth2.rfc6749.endpoints.pre_configured This module is an implementation of various endpoints needed for providing OAuth 2.0 RFC6749 servers. """ + from ..grant_types import ( - AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + AuthorizationCodeGrant, + ClientCredentialsGrant, + ImplicitGrant, + RefreshTokenGrant, + ResourceOwnerPasswordCredentialsGrant, ) from ..tokens import BearerToken from .authorization import AuthorizationEndpoint @@ -15,16 +19,26 @@ from .introspect import IntrospectEndpoint from .resource import ResourceEndpoint from .revocation import RevocationEndpoint from .token import TokenEndpoint +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant -class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): +class Server( + AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, ResourceEndpoint, RevocationEndpoint +): + """ + An all-in-one endpoint featuring all four major grant types + and extension grants. + """ - """An all-in-one endpoint featuring all four major grant types.""" - - def __init__(self, request_validator, token_expires_in=None, - token_generator=None, refresh_token_generator=None, - *args, **kwargs): + def __init__( + self, + request_validator, + token_expires_in=None, + token_generator=None, + refresh_token_generator=None, + *args, + **kwargs, + ): """Construct a new all-grants-in-one server. :param request_validator: An implementation of @@ -40,43 +54,58 @@ class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, """ self.auth_grant = AuthorizationCodeGrant(request_validator) self.implicit_grant = ImplicitGrant(request_validator) - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.credentials_grant = ClientCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) + self.device_code_grant = DeviceCodeGrant(request_validator, **kwargs) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={ - 'code': self.auth_grant, - 'token': self.implicit_grant, - 'none': self.auth_grant - }, - default_token_type=self.bearer) + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={ + "code": self.auth_grant, + "token": self.implicit_grant, + "none": self.auth_grant, + }, + default_token_type=self.bearer, + ) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.auth_grant, - 'password': self.password_grant, - 'client_credentials': self.credentials_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.auth_grant, + "password": self.password_grant, + "client_credentials": self.credentials_grant, + "refresh_token": self.refresh_grant, + "urn:ietf:params:oauth:grant-type:device_code": self.device_code_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class WebApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class WebApplicationServer( + AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a new web application server. :param request_validator: An implementation of @@ -92,30 +121,44 @@ class WebApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpo """ self.auth_grant = AuthorizationCodeGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={'code': self.auth_grant}, - default_token_type=self.bearer) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.auth_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={"code": self.auth_grant}, + default_token_type=self.bearer, + ) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.auth_grant, + "refresh_token": self.refresh_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class MobileApplicationServer( + AuthorizationEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Implicit code grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a new implicit grant server. :param request_validator: An implementation of @@ -130,27 +173,39 @@ class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, token-, resource-, and revocation-endpoint constructors. """ self.implicit_grant = ImplicitGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='token', - response_types={ - 'token': self.implicit_grant}, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) - RevocationEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + AuthorizationEndpoint.__init__( + self, + default_response_type="token", + response_types={"token": self.implicit_grant}, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) + RevocationEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + IntrospectEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) -class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class LegacyApplicationServer( + TokenEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Resource Owner Password Credentials grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a resource owner password credentials grant server. :param request_validator: An implementation of @@ -164,30 +219,40 @@ class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, :param kwargs: Extra parameters to pass to authorization-, token-, resource-, and revocation-endpoint constructors. """ - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - TokenEndpoint.__init__(self, default_grant_type='password', - grant_types={ - 'password': self.password_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + TokenEndpoint.__init__( + self, + default_grant_type="password", + grant_types={ + "password": self.password_grant, + "refresh_token": self.refresh_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class BackendApplicationServer(TokenEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class BackendApplicationServer( + TokenEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Client Credentials grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a client credentials grant server. :param request_validator: An implementation of @@ -202,15 +267,21 @@ class BackendApplicationServer(TokenEndpoint, IntrospectEndpoint, token-, resource-, and revocation-endpoint constructors. """ self.credentials_grant = ClientCredentialsGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - TokenEndpoint.__init__(self, default_grant_type='client_credentials', - grant_types={ - 'client_credentials': self.credentials_grant}, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) - RevocationEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + TokenEndpoint.__init__( + self, + default_grant_type="client_credentials", + grant_types={"client_credentials": self.credentials_grant}, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) + RevocationEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + IntrospectEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) diff --git a/oauthlib/oauth2/rfc6749/endpoints/resource.py b/oauthlib/oauth2/rfc6749/endpoints/resource.py index f756225..d1ff504 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/resource.py +++ b/oauthlib/oauth2/rfc6749/endpoints/resource.py @@ -81,4 +81,4 @@ class ResourceEndpoint(BaseEndpoint): """ estimates = sorted(((t.estimate_type(request), n) for n, t in self.tokens.items()), reverse=True) - return estimates[0][1] if len(estimates) else None + return estimates[0][1] if estimates else None diff --git a/oauthlib/oauth2/rfc6749/errors.py b/oauthlib/oauth2/rfc6749/errors.py index da24fea..be8e7a1 100644 --- a/oauthlib/oauth2/rfc6749/errors.py +++ b/oauthlib/oauth2/rfc6749/errors.py @@ -6,6 +6,8 @@ Error used both by OAuth 2 clients and providers to represent the spec defined error responses for all four core grant types. """ import json +import inspect +import sys from oauthlib.common import add_params_to_uri, urlencode @@ -60,7 +62,7 @@ class OAuth2Error(Exception): self.response_type = request.response_type self.response_mode = request.response_mode self.grant_type = request.grant_type - if not state: + if state is None: self.state = request.state else: self.redirect_uri = None @@ -150,7 +152,6 @@ class FatalClientError(OAuth2Error): Instead the user should be informed of the error by the provider itself. """ - pass class InvalidRequestFatalError(FatalClientError): @@ -387,8 +388,6 @@ class CustomOAuth2Error(OAuth2Error): def raise_from_error(error, params=None): - import inspect - import sys kwargs = { 'description': params.get('error_description'), 'uri': params.get('error_uri'), diff --git a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py index 858855a..09dc619 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py +++ b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -387,7 +387,7 @@ class AuthorizationCodeGrant(GrantTypeBase): raise errors.MissingResponseTypeError(request=request) # Value MUST be set to "code" or one of the OpenID authorization code including # response_types "code token", "code id_token", "code token id_token" - elif not 'code' in request.response_type and request.response_type != 'none': + elif 'code' not in request.response_type and request.response_type != 'none': raise errors.UnsupportedResponseTypeError(request=request) if not self.request_validator.validate_response_type(request.client_id, @@ -400,9 +400,8 @@ class AuthorizationCodeGrant(GrantTypeBase): # OPTIONAL. Validate PKCE request or reply with "error"/"invalid_request" # https://tools.ietf.org/html/rfc6749#section-4.4.1 - if self.request_validator.is_pkce_required(request.client_id, request) is True: - if request.code_challenge is None: - raise errors.MissingCodeChallengeError(request=request) + if self.request_validator.is_pkce_required(request.client_id, request) is True and request.code_challenge is None: + raise errors.MissingCodeChallengeError(request=request) if request.code_challenge is not None: request_info["code_challenge"] = request.code_challenge diff --git a/oauthlib/oauth2/rfc6749/grant_types/base.py b/oauthlib/oauth2/rfc6749/grant_types/base.py index ca343a1..d96a2db 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/base.py +++ b/oauthlib/oauth2/rfc6749/grant_types/base.py @@ -143,7 +143,7 @@ class GrantTypeBase: :type request: oauthlib.common.Request """ # Only add a hybrid access token on auth step if asked for - if not request.response_type in ["token", "code token", "id_token token", "code id_token token"]: + if request.response_type not in ["token", "code token", "id_token token", "code id_token token"]: return token token.update(token_handler.create_token(request, refresh_token=False)) @@ -199,10 +199,7 @@ class GrantTypeBase: if request.response_type == 'none': state = token.get('state', None) - if state: - token_items = [('state', state)] - else: - token_items = [] + token_items = [('state', state)] if state else [] if request.response_mode == 'query': headers['Location'] = add_params_to_uri( diff --git a/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py b/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py index e7b4618..35c5440 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py +++ b/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py @@ -107,11 +107,10 @@ class ClientCredentialsGrant(GrantTypeBase): if not self.request_validator.authenticate_client(request): log.debug('Client authentication failed, %r.', request) raise errors.InvalidClientError(request=request) - else: - if not hasattr(request.client, 'client_id'): - raise NotImplementedError('Authenticate client must set the ' - 'request.client.client_id attribute ' - 'in authenticate_client.') + elif not hasattr(request.client, 'client_id'): + raise NotImplementedError('Authenticate client must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') # Ensure client is authorized use of this grant type self.validate_grant_type(request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/implicit.py b/oauthlib/oauth2/rfc6749/grant_types/implicit.py index 6110b6f..cd3bfeb 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/implicit.py +++ b/oauthlib/oauth2/rfc6749/grant_types/implicit.py @@ -233,10 +233,7 @@ class ImplicitGrant(GrantTypeBase): # In OIDC implicit flow it is possible to have a request_type that does not include the access_token! # "id_token token" - return the access token and the id token # "id_token" - don't return the access token - if "token" in request.response_type.split(): - token = token_handler.create_token(request, refresh_token=False) - else: - token = {} + token = token_handler.create_token(request, refresh_token=False) if 'token' in request.response_type.split() else {} if request.state is not None: token['state'] = request.state diff --git a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py index ce33df0..43bf55a 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py +++ b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -101,6 +101,9 @@ class RefreshTokenGrant(GrantTypeBase): if not self.request_validator.authenticate_client(request): log.debug('Invalid client (%r), denying access.', request) raise errors.InvalidClientError(request=request) + # Ensure that request.client_id is set. + if request.client_id is None and request.client is not None: + request.client_id = request.client.client_id elif not self.request_validator.authenticate_client_id(request.client_id, request): log.debug('Client authentication failed, %r.', request) raise errors.InvalidClientError(request=request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py b/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py index 4b0de5b..55d9287 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py +++ b/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py @@ -180,12 +180,11 @@ class ResourceOwnerPasswordCredentialsGrant(GrantTypeBase): request.password, request.client, request): raise errors.InvalidGrantError( 'Invalid credentials given.', request=request) - else: - if not hasattr(request.client, 'client_id'): - raise NotImplementedError( - 'Validate user must set the ' - 'request.client.client_id attribute ' - 'in authenticate_client.') + elif not hasattr(request.client, 'client_id'): + raise NotImplementedError( + 'Validate user must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') log.debug('Authorizing access to user %r.', request.user) # Ensure client is authorized use of this grant type diff --git a/oauthlib/oauth2/rfc6749/parameters.py b/oauthlib/oauth2/rfc6749/parameters.py index 8f6ce2c..8268ef9 100644 --- a/oauthlib/oauth2/rfc6749/parameters.py +++ b/oauthlib/oauth2/rfc6749/parameters.py @@ -45,10 +45,10 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in `Section 10.12`_. - :param code_challenge: PKCE parameter. A challenge derived from the - code_verifier that is sent in the authorization + :param code_challenge: PKCE parameter. A challenge derived from the + code_verifier that is sent in the authorization request, to be verified against later. - :param code_challenge_method: PKCE parameter. A method that was used to derive the + :param code_challenge_method: PKCE parameter. A method that was used to derive the code_challenge. Defaults to "plain" if not present in the request. :param kwargs: Extra arguments to embed in the grant/authorization URL. @@ -150,9 +150,8 @@ def prepare_token_request(grant_type, body='', include_client_id=True, code_veri # pull the `client_id` out of the kwargs. client_id = kwargs.pop('client_id', None) - if include_client_id: - if client_id is not None: - params.append(('client_id', client_id)) + if include_client_id and client_id is not None: + params.append(('client_id', client_id)) # use code_verifier if code_challenge was passed in the authorization request if code_verifier is not None: @@ -274,13 +273,13 @@ def parse_authorization_code_response(uri, state=None): query = urlparse.urlparse(uri).query params = dict(urlparse.parse_qsl(query)) - if state and params.get('state', None) != state: + if state and params.get('state') != state: raise MismatchingStateError() if 'error' in params: raise_from_error(params.get('error'), params) - if not 'code' in params: + if 'code' not in params: raise MissingCodeError("Missing code parameter in response.") return params @@ -337,17 +336,20 @@ def parse_implicit_response(uri, state=None, scope=None): fragment = urlparse.urlparse(uri).fragment params = dict(urlparse.parse_qsl(fragment, keep_blank_values=True)) - for key in ('expires_in',): - if key in params: # cast things to int - params[key] = int(params[key]) - if 'scope' in params: params['scope'] = scope_to_list(params['scope']) - if 'expires_in' in params: - params['expires_at'] = time.time() + int(params['expires_in']) + vin, vat, v_at = parse_expires(params) + if vin: + params['expires_in'] = vin + elif 'expires_in' in params: + params.pop('expires_in') + if vat: + params['expires_at'] = vat + elif 'expires_at' in params: + params.pop('expires_at') - if state and params.get('state', None) != state: + if state and params.get('state') != state: raise ValueError("Mismatching or missing state in params.") params = OAuth2Token(params, old_scope=scope) @@ -424,18 +426,19 @@ def parse_token_response(body, scope=None): # https://github.com/oauthlib/oauthlib/issues/267 params = dict(urlparse.parse_qsl(body)) - for key in ('expires_in',): - if key in params: # cast things to int - params[key] = int(params[key]) if 'scope' in params: params['scope'] = scope_to_list(params['scope']) - if 'expires_in' in params: - if params['expires_in'] is None: - params.pop('expires_in') - else: - params['expires_at'] = time.time() + int(params['expires_in']) + vin, vat, v_at = parse_expires(params) + if vin: + params['expires_in'] = vin + elif 'expires_in' in params: + params.pop('expires_in') + if vat: + params['expires_at'] = vat + elif 'expires_at' in params: + params.pop('expires_at') params = OAuth2Token(params, old_scope=scope) validate_token_parameters(params) @@ -447,12 +450,11 @@ def validate_token_parameters(params): if 'error' in params: raise_from_error(params.get('error'), params) - if not 'access_token' in params: + if 'access_token' not in params: raise MissingTokenError(description="Missing access token parameter.") - if not 'token_type' in params: - if os.environ.get('OAUTHLIB_STRICT_TOKEN_TYPE'): - raise MissingTokenTypeError() + if 'token_type' not in params and os.environ.get('OAUTHLIB_STRICT_TOKEN_TYPE'): + raise MissingTokenTypeError() # If the issued access token scope is different from the one requested by # the client, the authorization server MUST include the "scope" response @@ -469,3 +471,58 @@ def validate_token_parameters(params): w.old_scope = params.old_scopes w.new_scope = params.scopes raise w + +def parse_expires(params): + """Parse `expires_in`, `expires_at` fields from params + + Parse following these rules: + - `expires_in` must be either integer, float or None. If a float, it is converted into an integer. + - `expires_at` is not in specification so it does its best to: + - convert into a int, else + - convert into a float, else + - reuse the same type as-is (usually string) + - `_expires_at` is a special internal value returned to be always an `int`, based + either on the presence of `expires_at`, or reuse the current time plus + `expires_in`. This is typically used to validate token expiry. + + :param params: Dict with expires_in and expires_at optionally set + :return: Tuple of `expires_in`, `expires_at`, and `_expires_at`. None if not set. + """ + expires_in = None + expires_at = None + _expires_at = None + + if 'expires_in' in params: + if isinstance(params.get('expires_in'), int): + expires_in = params.get('expires_in') + elif isinstance(params.get('expires_in'), float): + expires_in = int(params.get('expires_in')) + elif isinstance(params.get('expires_in'), str): + try: + # Attempt to convert to int + expires_in = int(params.get('expires_in')) + except ValueError: + raise ValueError("expires_in must be an int") + elif params.get('expires_in') is not None: + raise ValueError("expires_in must be an int") + + if 'expires_at' in params: + if isinstance(params.get('expires_at'), (float, int)): + expires_at = params.get('expires_at') + _expires_at = expires_at + elif isinstance(params.get('expires_at'), str): + try: + # Attempt to convert to int first, then float if int fails + expires_at = int(params.get('expires_at')) + _expires_at = expires_at + except ValueError: + try: + expires_at = float(params.get('expires_at')) + _expires_at = expires_at + except ValueError: + # no change from str + expires_at = params.get('expires_at') + if _expires_at is None and expires_in: + expires_at = round(time.time()) + expires_in + _expires_at = expires_at + return expires_in, expires_at, _expires_at diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py index 3910c0b..6d6ebaa 100644 --- a/oauthlib/oauth2/rfc6749/request_validator.py +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -48,12 +48,12 @@ class RequestValidator: Headers may be accesses through request.headers and parameters found in both body and query can be obtained by direct attribute access, i.e. request.client_id for client_id in the URL query. - + The authentication process is required to contain the identification of the client (i.e. search the database based on the client_id). In case the client doesn't exist based on the received client_id, this method has to return False and the HTTP response created by the library will contain - 'invalid_client' message. + 'invalid_client' message. After the client identification succeeds, this method needs to set the client on the request, i.e. request.client = client. A client object's diff --git a/oauthlib/oauth2/rfc6749/tokens.py b/oauthlib/oauth2/rfc6749/tokens.py index 0757d07..73b8c66 100644 --- a/oauthlib/oauth2/rfc6749/tokens.py +++ b/oauthlib/oauth2/rfc6749/tokens.py @@ -24,7 +24,7 @@ class OAuth2Token(dict): def __init__(self, params, old_scope=None): super().__init__(params) self._new_scope = None - if 'scope' in params and params['scope']: + if params.get('scope'): self._new_scope = set(utils.scope_to_list(params['scope'])) if old_scope is not None: self._old_scope = set(utils.scope_to_list(old_scope)) @@ -123,10 +123,7 @@ def prepare_mac_header(token, uri, key, http_method, sch, net, path, par, query, fra = urlparse(uri) - if query: - request_uri = path + '?' + query - else: - request_uri = path + request_uri = path + '?' + query if query else path # Hash the body/payload if body is not None and draft == 0: @@ -305,10 +302,7 @@ class BearerToken(TokenBase): "If you do, call `request_validator.save_token()` instead.", DeprecationWarning) - if callable(self.expires_in): - expires_in = self.expires_in(request) - else: - expires_in = self.expires_in + expires_in = self.expires_in(request) if callable(self.expires_in) else self.expires_in request.expires_in = expires_in diff --git a/oauthlib/oauth2/rfc8628/__init__.py b/oauthlib/oauth2/rfc8628/__init__.py index 531929d..6589144 100644 --- a/oauthlib/oauth2/rfc8628/__init__.py +++ b/oauthlib/oauth2/rfc8628/__init__.py @@ -5,6 +5,12 @@ 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.rfc8628.errors import ( + SlowDownError, + AuthorizationPendingError, + ExpiredTokenError, +) import logging log = logging.getLogger(__name__) diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py index b9ba215..ee0ccf8 100644 --- a/oauthlib/oauth2/rfc8628/clients/device.py +++ b/oauthlib/oauth2/rfc8628/clients/device.py @@ -45,9 +45,9 @@ class DeviceClient(Client): if scope: params.append(('scope', list_to_scope(scope))) - for k in kwargs: - if kwargs[k]: - params.append((str(k), kwargs[k])) + for k,v in kwargs.items(): + if v: + params.append((str(k), v)) return add_params_to_uri(uri, params) diff --git a/oauthlib/oauth2/rfc8628/endpoints/__init__.py b/oauthlib/oauth2/rfc8628/endpoints/__init__.py new file mode 100644 index 0000000..dc83479 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/endpoints/__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. +""" + +from .device_authorization import DeviceAuthorizationEndpoint +from .pre_configured import DeviceApplicationServer diff --git a/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py b/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py new file mode 100644 index 0000000..3f38a54 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py @@ -0,0 +1,232 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC8628. +""" + +import logging +from typing import Callable + +from oauthlib.common import Request, generate_token +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.endpoints.base import ( + BaseEndpoint, + catch_errors_and_unavailability, +) + +log = logging.getLogger(__name__) + + +class DeviceAuthorizationEndpoint(BaseEndpoint): + """DeviceAuthorization endpoint - used by the client to initiate + the authorization flow by requesting a set of verification codes + from the authorization server by making an HTTP "POST" request to + the device authorization endpoint. + + The client authentication requirements of Section 3.2.1 of [RFC6749] + apply to requests on this endpoint, which means that confidential + clients (those that have established client credentials) authenticate + in the same manner as when making requests to the token endpoint, and + public clients provide the "client_id" parameter to identify + themselves. + """ + + def __init__( + self, + request_validator, + verification_uri, + expires_in=1800, + interval=None, + verification_uri_complete=None, + user_code_generator: Callable[[None], str] = None, + ): + """ + :param request_validator: An instance of RequestValidator. + :type request_validator: oauthlib.oauth2.rfc6749.RequestValidator. + :param verification_uri: a string containing the URL that can be polled by the client application + :param expires_in: a number that represents the lifetime of the `user_code` and `device_code` + :param interval: an option number that represents the number of seconds between each poll requests + :param verification_uri_complete: a string of a function that can be called with `user_data` as parameter + :param user_code_generator: a callable that returns a configurable user code + """ + self.request_validator = request_validator + self._expires_in = expires_in + self._interval = interval + self._verification_uri = verification_uri + self._verification_uri_complete = verification_uri_complete + self.user_code_generator = user_code_generator + + BaseEndpoint.__init__(self) + + @property + def interval(self): + """The minimum amount of time in seconds that the client + SHOULD wait between polling requests to the token endpoint. If no + value is provided, clients MUST use 5 as the default. + """ + return self._interval + + @property + def expires_in(self): + """The lifetime in seconds of the "device_code" and "user_code".""" + return self._expires_in + + @property + def verification_uri(self): + """The end-user verification URI on the authorization + server. The URI should be short and easy to remember as end users + will be asked to manually type it into their user agent. + """ + return self._verification_uri + + def verification_uri_complete(self, user_code): + if not self._verification_uri_complete: + return None + if isinstance(self._verification_uri_complete, str): + return self._verification_uri_complete.format(user_code=user_code) + if callable(self._verification_uri_complete): + return self._verification_uri_complete(user_code) + return None + + @catch_errors_and_unavailability + def validate_device_authorization_request(self, request): + """Validate the device authorization request. + + The client_id is required if the client is not authenticating with the + authorization server as described in `Section 3.2.1. of [RFC6749]`_. + The client identifier as described in `Section 2.2 of [RFC6749]`_. + + .. _`Section 3.2.1. of [RFC6749]`: https://www.rfc-editor.org/rfc/rfc6749#section-3.2.1 + .. _`Section 2.2 of [RFC6749]`: https://www.rfc-editor.org/rfc/rfc6749#section-2.2 + """ + + # First check duplicate parameters + for param in ("client_id", "scope"): + try: + duplicate_params = request.duplicate_params + except ValueError: + raise errors.InvalidRequestFatalError( + description="Unable to parse query string", request=request + ) + if param in duplicate_params: + raise errors.InvalidRequestFatalError( + description="Duplicate %s parameter." % param, request=request + ) + + # the "application/x-www-form-urlencoded" format, per Appendix B of [RFC6749] + # https://www.rfc-editor.org/rfc/rfc6749#appendix-B + if request.headers["Content-Type"] != "application/x-www-form-urlencoded": + raise errors.InvalidRequestError( + "Content-Type must be application/x-www-form-urlencoded", + request=request, + ) + + # REQUIRED. The client identifier as described in Section 2.2. + # https://tools.ietf.org/html/rfc6749#section-2.2 + # TODO: extract client_id an helper validation function. + if not request.client_id: + raise errors.MissingClientIdError(request=request) + + if not self.request_validator.validate_client_id(request.client_id, request): + raise errors.InvalidClientIdError(request=request) + + # The client authentication requirements of Section 3.2.1 of [RFC6749] + # apply to requests on this endpoint, which means that confidential + # clients (those that have established client credentials) authenticate + # in the same manner as when making requests to the token endpoint, and + # public clients provide the "client_id" parameter to identify + # themselves. + self._raise_on_invalid_client(request) + + @catch_errors_and_unavailability + def create_device_authorization_response( + self, uri, http_method="POST", body=None, headers=None + ): + """ + Generate a unique device verification code and an end-user code that are valid for a limited time. + Include them in the HTTP response body using the "application/json" format [RFC8259] with a + 200 (OK) status code, as described in `Section-3.2`_. + + :param uri: The full URI of the token request. + :type uri: str + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param user_code_generator: + A callable that returns a string for the user code. + This allows the caller to decide how the `user_code` should be formatted. + :type user_code_generator: Callable[[], str] + :return: A tuple of three elements: + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + :rtype: tuple + + The response contains the following parameters: + + device_code + **REQUIRED.** The device verification code. + + user_code + **REQUIRED.** The end-user verification code. + + verification_uri + **REQUIRED.** The end-user verification URI on the authorization server. + The URI should be short and easy to remember as end users will be asked + to manually type it into their user agent. + + verification_uri_complete + **OPTIONAL.** A verification URI that includes the `user_code` (or + other information with the same function as the `user_code`), which is + designed for non-textual transmission. + + expires_in + **REQUIRED.** The lifetime in seconds of the `device_code` and `user_code`. + + interval + **OPTIONAL.** The minimum amount of time in seconds that the client + SHOULD wait between polling requests to the token endpoint. If no + value is provided, clients MUST use 5 as the default. + + **For example:** + + .. code-block:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "verification_uri_complete": + "https://example.com/device?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 5 + } + + .. _`Section-3.2`: https://www.rfc-editor.org/rfc/rfc8628#section-3.2 + """ + request = Request(uri, http_method, body, headers) + self.validate_device_authorization_request(request) + log.debug("Pre resource owner authorization validation ok for %r.", request) + + headers = {} + user_code = self.user_code_generator() if self.user_code_generator else generate_token() + data = { + "verification_uri": self.verification_uri, + "expires_in": self.expires_in, + "user_code": user_code, + "device_code": generate_token(), + } + if self.interval is not None: + data["interval"] = self.interval + + + verification_uri_complete = self.verification_uri_complete(user_code) + if verification_uri_complete: + data["verification_uri_complete"] = verification_uri_complete + + return headers, data, 200 diff --git a/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py b/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py new file mode 100644 index 0000000..6cce683 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py @@ -0,0 +1,36 @@ +from oauthlib.oauth2.rfc8628.endpoints.device_authorization import ( + DeviceAuthorizationEndpoint, +) + +from typing import Callable, Optional +from oauthlib.openid.connect.core.request_validator import RequestValidator + + +class DeviceApplicationServer(DeviceAuthorizationEndpoint): + """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" + + def __init__( + self, + request_validator: RequestValidator, + verification_uri: str, + interval: int = 5, + verification_uri_complete: Optional[str] = None, # noqa: FA100 + user_code_generator: Callable[[None], str] = None, + **kwargs, + ): + """Construct a new web application server. + + :param request_validator: An implementation of + oauthlib.oauth2.rfc8626.RequestValidator. + :param interval: How long the device needs to wait before polling the server + :param verification_uri: the verification_uri to be send back. + :param user_code_generator: a callable that allows the user code to be configured. + """ + DeviceAuthorizationEndpoint.__init__( + self, + request_validator, + interval=interval, + verification_uri=verification_uri, + user_code_generator=user_code_generator, + verification_uri_complete=verification_uri_complete, + ) diff --git a/oauthlib/oauth2/rfc8628/errors.py b/oauthlib/oauth2/rfc8628/errors.py new file mode 100644 index 0000000..a435938 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/errors.py @@ -0,0 +1,55 @@ +from oauthlib.oauth2.rfc6749.errors import OAuth2Error + +""" +oauthlib.oauth2.rfc8628.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth2 clients and providers to represent the spec +defined error responses specific to the the device grant +""" + + +class AuthorizationPendingError(OAuth2Error): + """ + For the device authorization grant; + The authorization request is still pending as the end user hasn't + yet completed the user-interaction steps (Section 3.3). The + client SHOULD repeat the access token request to the token + endpoint (a process known as polling). Before each new request, + the client MUST wait at least the number of seconds specified by + the "interval" parameter of the device authorization response, + or 5 seconds if none was provided, and respect any + increase in the polling interval required by the "slow_down" + error. + """ + + error = "authorization_pending" + + +class SlowDownError(OAuth2Error): + """ + A variant of "authorization_pending", the authorization request is + still pending and polling should continue, but the interval MUST + be increased by 5 seconds for this and all subsequent requests. + """ + + error = "slow_down" + + +class ExpiredTokenError(OAuth2Error): + """ + The "device_code" has expired, and the device authorization + session has concluded. The client MAY commence a new device + authorization request but SHOULD wait for user interaction before + restarting to avoid unnecessary polling. + """ + + error = "expired_token" + + +class AccessDenied(OAuth2Error): + """ + The authorization request was denied. + """ + + error = "access_denied" diff --git a/oauthlib/oauth2/rfc8628/grant_types/__init__.py b/oauthlib/oauth2/rfc8628/grant_types/__init__.py new file mode 100644 index 0000000..418dba7 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/grant_types/__init__.py @@ -0,0 +1 @@ +from oauthlib.oauth2.rfc8628.grant_types.device_code import DeviceCodeGrant diff --git a/oauthlib/oauth2/rfc8628/grant_types/device_code.py b/oauthlib/oauth2/rfc8628/grant_types/device_code.py new file mode 100644 index 0000000..082daf0 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/grant_types/device_code.py @@ -0,0 +1,111 @@ +from __future__ import annotations +import json + +from typing import Callable + +from oauthlib import common # noqa: TC001 + +from oauthlib.oauth2.rfc6749 import errors as rfc6749_errors +from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase + + +class DeviceCodeGrant(GrantTypeBase): + def create_authorization_response( + self, request: common.Request, token_handler: Callable + ) -> tuple[dict, str, int]: + """ + Validate the device flow request -> create the access token + -> persist the token -> return the token. + """ + headers = self._get_default_headers() + try: + self.validate_token_request(request) + except rfc6749_errors.OAuth2Error as e: + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=False) + + for modifier in self._token_modifiers: + token = modifier(token) + + self.request_validator.save_token(token, request) + + return self.create_token_response(request, token_handler) + + def validate_token_request(self, request: common.Request) -> None: + """ + Performs the necessary check against the request to ensure + it's allowed to retrieve a token. + """ + for validator in self.custom_validators.pre_token: + validator(request) + + if not getattr(request, "grant_type", None): + raise rfc6749_errors.InvalidRequestError( + "Request is missing grant type.", request=request + ) + + if request.grant_type != "urn:ietf:params:oauth:grant-type:device_code": + raise rfc6749_errors.UnsupportedGrantTypeError(request=request) + + for param in ("grant_type", "scope"): + if param in request.duplicate_params: + raise rfc6749_errors.InvalidRequestError( + description=f"Duplicate {param} parameter.", request=request + ) + + if not self.request_validator.authenticate_client(request): + raise rfc6749_errors.InvalidClientError(request=request) + elif not hasattr(request.client, "client_id"): + raise NotImplementedError( + "Authenticate client must set the " + "request.client.client_id attribute " + "in authenticate_client." + ) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + request.client_id = request.client_id or request.client.client_id + self.validate_scopes(request) + + for validator in self.custom_validators.post_token: + validator(request) + + def create_token_response( + self, request: common.Request, token_handler: Callable + ) -> tuple[dict, str, int]: + """Return token or error in json format. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If the access token request is valid and authorized, the + authorization server issues an access token and optional refresh + token as described in `Section 5.1`_. If the request failed client + authentication or is invalid, the authorization server returns an + error response as described in `Section 5.2`_. + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = self._get_default_headers() + try: + if self.request_validator.client_authentication_required( + request + ) and not self.request_validator.authenticate_client(request): + raise rfc6749_errors.InvalidClientError(request=request) + + self.validate_token_request(request) + + except rfc6749_errors.OAuth2Error as e: + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, self.refresh_token) + + self.request_validator.save_token(token, request) + + return headers, json.dumps(token), 200 diff --git a/oauthlib/oauth2/rfc8628/request_validator.py b/oauthlib/oauth2/rfc8628/request_validator.py new file mode 100644 index 0000000..70ee782 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/request_validator.py @@ -0,0 +1,25 @@ +from oauthlib.oauth2 import RequestValidator as OAuth2RequestValidator + + +class RequestValidator(OAuth2RequestValidator): + def client_authentication_required(self, request, *args, **kwargs): + """Determine if client authentication is required for current request. + + According to the rfc8628, client authentication is required in the following cases: + - Device Authorization Request follows the, the client authentication requirements + of Section 3.2.1 of [RFC6749] apply to requests on this endpoint, which means that + confidential clients (those that have established client credentials) authenticate + in the same manner as when making requests to the token endpoint, and + public clients provide the "client_id" parameter to identify themselves, + see `Section 3.1`_. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Device Authorization Request + + .. _`Section 3.1`: https://www.rfc-editor.org/rfc/rfc8628#section-3.1 + """ + return True diff --git a/oauthlib/openid/connect/core/endpoints/pre_configured.py b/oauthlib/openid/connect/core/endpoints/pre_configured.py index 8ce8bee..17df516 100644 --- a/oauthlib/openid/connect/core/endpoints/pre_configured.py +++ b/oauthlib/openid/connect/core/endpoints/pre_configured.py @@ -5,34 +5,60 @@ oauthlib.openid.connect.core.endpoints.pre_configured This module is an implementation of various endpoints needed for providing OpenID Connect servers. """ + from oauthlib.oauth2.rfc6749.endpoints import ( - AuthorizationEndpoint, IntrospectEndpoint, ResourceEndpoint, - RevocationEndpoint, TokenEndpoint, + AuthorizationEndpoint, + IntrospectEndpoint, + ResourceEndpoint, + RevocationEndpoint, + TokenEndpoint, ) from oauthlib.oauth2.rfc6749.grant_types import ( AuthorizationCodeGrant as OAuth2AuthorizationCodeGrant, - ClientCredentialsGrant, ImplicitGrant as OAuth2ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + ClientCredentialsGrant, + ImplicitGrant as OAuth2ImplicitGrant, + ResourceOwnerPasswordCredentialsGrant, ) +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant from oauthlib.oauth2.rfc6749.tokens import BearerToken -from ..grant_types import AuthorizationCodeGrant, HybridGrant, ImplicitGrant +from ..grant_types import ( + AuthorizationCodeGrant, + HybridGrant, + ImplicitGrant, + RefreshTokenGrant, +) from ..grant_types.dispatchers import ( - AuthorizationCodeGrantDispatcher, AuthorizationTokenGrantDispatcher, + AuthorizationCodeGrantDispatcher, + AuthorizationTokenGrantDispatcher, ImplicitTokenGrantDispatcher, ) from ..tokens import JWTToken from .userinfo import UserInfoEndpoint -class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint, UserInfoEndpoint): +class Server( + AuthorizationEndpoint, + IntrospectEndpoint, + TokenEndpoint, + ResourceEndpoint, + RevocationEndpoint, + UserInfoEndpoint, +): + """ + An all-in-one endpoint featuring all four major grant types + and extension grants. + """ - """An all-in-one endpoint featuring all four major grant types.""" - - def __init__(self, request_validator, token_expires_in=None, - token_generator=None, refresh_token_generator=None, - *args, **kwargs): + def __init__( + self, + request_validator, + token_expires_in=None, + token_generator=None, + refresh_token_generator=None, + *args, + **kwargs, + ): """Construct a new all-grants-in-one server. :param request_validator: An implementation of @@ -48,50 +74,66 @@ class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, """ self.auth_grant = OAuth2AuthorizationCodeGrant(request_validator) self.implicit_grant = OAuth2ImplicitGrant(request_validator) - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.credentials_grant = ClientCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) self.openid_connect_auth = AuthorizationCodeGrant(request_validator) self.openid_connect_implicit = ImplicitGrant(request_validator) self.openid_connect_hybrid = HybridGrant(request_validator) + self.device_code_grant = DeviceCodeGrant(request_validator, **kwargs) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) - self.jwt = JWTToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) + self.jwt = JWTToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) - self.auth_grant_choice = AuthorizationCodeGrantDispatcher(default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth) - self.implicit_grant_choice = ImplicitTokenGrantDispatcher(default_grant=self.implicit_grant, oidc_grant=self.openid_connect_implicit) + self.auth_grant_choice = AuthorizationCodeGrantDispatcher( + default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth + ) + self.implicit_grant_choice = ImplicitTokenGrantDispatcher( + default_grant=self.implicit_grant, oidc_grant=self.openid_connect_implicit + ) # See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Combinations for valid combinations # internally our AuthorizationEndpoint will ensure they can appear in any order for any valid combination - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={ - 'code': self.auth_grant_choice, - 'token': self.implicit_grant_choice, - 'id_token': self.openid_connect_implicit, - 'id_token token': self.openid_connect_implicit, - 'code token': self.openid_connect_hybrid, - 'code id_token': self.openid_connect_hybrid, - 'code id_token token': self.openid_connect_hybrid, - 'none': self.auth_grant - }, - default_token_type=self.bearer) + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={ + "code": self.auth_grant_choice, + "token": self.implicit_grant_choice, + "id_token": self.openid_connect_implicit, + "id_token token": self.openid_connect_implicit, + "code token": self.openid_connect_hybrid, + "code id_token": self.openid_connect_hybrid, + "code id_token token": self.openid_connect_hybrid, + "none": self.auth_grant, + }, + default_token_type=self.bearer, + ) - self.token_grant_choice = AuthorizationTokenGrantDispatcher(request_validator, default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth) + self.token_grant_choice = AuthorizationTokenGrantDispatcher( + request_validator, default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth + ) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.token_grant_choice, - 'password': self.password_grant, - 'client_credentials': self.credentials_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer, 'JWT': self.jwt}) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.token_grant_choice, + "password": self.password_grant, + "client_credentials": self.credentials_grant, + "refresh_token": self.refresh_grant, + "urn:ietf:params:oauth:grant-type:device_code": self.device_code_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer, "JWT": self.jwt} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) UserInfoEndpoint.__init__(self, request_validator) diff --git a/oauthlib/openid/connect/core/exceptions.py b/oauthlib/openid/connect/core/exceptions.py index 099b84e..291cf13 100644 --- a/oauthlib/openid/connect/core/exceptions.py +++ b/oauthlib/openid/connect/core/exceptions.py @@ -5,6 +5,9 @@ oauthlib.oauth2.rfc6749.errors Error used both by OAuth 2 clients and providers to represent the spec defined error responses for all four core grant types. """ +import inspect +import sys + from oauthlib.oauth2.rfc6749.errors import FatalClientError, OAuth2Error @@ -72,8 +75,8 @@ class InvalidRequestURI(OpenIDClientError): contains invalid data. """ error = 'invalid_request_uri' - description = 'The request_uri in the Authorization Request returns an ' \ - 'error or contains invalid data.' + description = ('The request_uri in the Authorization Request returns an ' + 'error or contains invalid data.') class InvalidRequestObject(OpenIDClientError): @@ -137,8 +140,6 @@ class InsufficientScopeError(OAuth2Error): def raise_from_error(error, params=None): - import inspect - import sys kwargs = { 'description': params.get('error_description'), 'uri': params.get('error_uri'), diff --git a/oauthlib/openid/connect/core/grant_types/base.py b/oauthlib/openid/connect/core/grant_types/base.py index 33411da..29d583e 100644 --- a/oauthlib/openid/connect/core/grant_types/base.py +++ b/oauthlib/openid/connect/core/grant_types/base.py @@ -310,11 +310,15 @@ class GrantTypeBase: msg = "Session user does not match client supplied user." raise LoginRequired(request=request, description=msg) + ui_locales = request.ui_locales if request.ui_locales else [] + if hasattr(ui_locales, 'split'): + ui_locales = ui_locales.strip().split() + request_info = { 'display': request.display, 'nonce': request.nonce, 'prompt': prompt, - 'ui_locales': request.ui_locales.split() if request.ui_locales else [], + 'ui_locales': ui_locales, 'id_token_hint': request.id_token_hint, 'login_hint': request.login_hint, 'claims': request.claims diff --git a/oauthlib/openid/connect/core/grant_types/dispatchers.py b/oauthlib/openid/connect/core/grant_types/dispatchers.py index 5aa7d46..7e07396 100644 --- a/oauthlib/openid/connect/core/grant_types/dispatchers.py +++ b/oauthlib/openid/connect/core/grant_types/dispatchers.py @@ -80,9 +80,9 @@ class AuthorizationTokenGrantDispatcher(Dispatcher): handler = self.default_grant scopes = () parameters = dict(request.decoded_body) - client_id = parameters.get('client_id', None) - code = parameters.get('code', None) - redirect_uri = parameters.get('redirect_uri', None) + client_id = parameters.get('client_id') + code = parameters.get('code') + redirect_uri = parameters.get('redirect_uri') # If code is not present fallback to `default_grant` which will # raise an error for the missing `code` in `create_token_response` step. diff --git a/oauthlib/openid/connect/core/grant_types/hybrid.py b/oauthlib/openid/connect/core/grant_types/hybrid.py index 7cb0758..9c1fc70 100644 --- a/oauthlib/openid/connect/core/grant_types/hybrid.py +++ b/oauthlib/openid/connect/core/grant_types/hybrid.py @@ -54,10 +54,9 @@ class HybridGrant(GrantTypeBase): # Token. Sufficient entropy MUST be present in the `nonce` # values used to prevent attackers from guessing values. For # implementation notes, see Section 15.5.2. - if request.response_type in ["code id_token", "code id_token token"]: - if not request.nonce: - raise InvalidRequestError( - request=request, - description='Request is missing mandatory nonce parameter.' - ) + if request.response_type in ["code id_token", "code id_token token"] and not request.nonce: + raise InvalidRequestError( + request=request, + description='Request is missing mandatory nonce parameter.' + ) return request_info diff --git a/oauthlib/openid/connect/core/request_validator.py b/oauthlib/openid/connect/core/request_validator.py index 47c4cd9..e3cea79 100644 --- a/oauthlib/openid/connect/core/request_validator.py +++ b/oauthlib/openid/connect/core/request_validator.py @@ -143,7 +143,7 @@ class RequestValidator(OAuth2RequestValidator): Token MUST NOT be accepted by the RP when performing authentication with the OP. - Additionals claims must be added, note that `request.scope` + Additional claims must be added, note that `request.scope` should be used to determine the list of claims. More information can be found at `OpenID Connect Core#Claims`_ diff --git a/oauthlib/openid/connect/core/tokens.py b/oauthlib/openid/connect/core/tokens.py index 936ab52..3ab3549 100644 --- a/oauthlib/openid/connect/core/tokens.py +++ b/oauthlib/openid/connect/core/tokens.py @@ -27,10 +27,7 @@ class JWTToken(TokenBase): def create_token(self, request, refresh_token=False): """Create a JWT Token, using requestvalidator method.""" - if callable(self.expires_in): - expires_in = self.expires_in(request) - else: - expires_in = self.expires_in + expires_in = self.expires_in(request) if callable(self.expires_in) else self.expires_in request.expires_in = expires_in diff --git a/oauthlib/signals.py b/oauthlib/signals.py index 8fd347a..9538d09 100644 --- a/oauthlib/signals.py +++ b/oauthlib/signals.py @@ -7,7 +7,7 @@ signals_available = False try: from blinker import Namespace signals_available = True -except ImportError: # noqa +except ImportError: class Namespace: def signal(self, name, doc=None): return _FakeSignal(name, doc) @@ -26,7 +26,8 @@ except ImportError: # noqa raise RuntimeError('signalling support is unavailable ' 'because the blinker library is ' 'not installed.') - send = lambda *a, **kw: None + def send(*a, **kw): + return None connect = disconnect = has_receivers_for = receivers_for = \ temporarily_connected_to = connected_to = _fail del _fail diff --git a/oauthlib/uri_validate.py b/oauthlib/uri_validate.py index a6fe0fb..69d2c95 100644 --- a/oauthlib/uri_validate.py +++ b/oauthlib/uri_validate.py @@ -174,8 +174,7 @@ URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? (?: \# %(fragment)s URI_reference = r"^(?: %(URI)s | %(relative_ref)s )$" % locals() # absolute-URI = scheme ":" hier-part [ "?" query ] -absolute_URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? )$" % locals( -) +absolute_URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? )$" % locals() # noqa: N816 def is_uri(uri): diff --git a/setup.cfg b/setup.cfg index 044b9e2..9df636e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -license_file = LICENSE +license_files = LICENSE [isort] combine_as_imports = true diff --git a/setup.py b/setup.py index 0192458..a35de99 100755 --- a/setup.py +++ b/setup.py @@ -1,8 +1,9 @@ +#!/usr/bin/env python3 # Hack because logging + setuptools sucks. -try: +import contextlib +with contextlib.suppress(ImportError): import multiprocessing -except ImportError: - pass + from os.path import dirname, join @@ -27,14 +28,13 @@ setup( long_description=fread('README.rst'), long_description_content_type='text/x-rst', author='The OAuthlib Community', - author_email='idan@gazit.me', - maintainer='Ib Lundgren', - maintainer_email='ib.lundgren@gmail.com', + maintainer='Jonathan Huot', + maintainer_email='jonathan.huot@gmail.com', url='https://github.com/oauthlib/oauthlib', platforms='any', - license='BSD', - packages=find_packages(exclude=('docs', 'tests', 'tests.*')), - python_requires='>=3.6', + license='BSD-3-Clause', + packages=find_packages(exclude=('docs', 'examples', 'tests', 'tests.*')), + python_requires='>=3.8', extras_require={ 'rsa': rsa_require, 'signedtoken': signedtoken_require, @@ -44,18 +44,17 @@ setup( 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Intended Audience :: Developers', - 'License :: OSI Approved', - 'License :: OSI Approved :: BSD License', 'Operating System :: MacOS', 'Operating System :: POSIX', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: Implementation', 'Programming Language :: Python :: Implementation :: CPython', diff --git a/tests/oauth1/rfc5849/endpoints/test_base.py b/tests/oauth1/rfc5849/endpoints/test_base.py index e87f359..792aacc 100644 --- a/tests/oauth1/rfc5849/endpoints/test_base.py +++ b/tests/oauth1/rfc5849/endpoints/test_base.py @@ -304,7 +304,7 @@ class ClientValidator(RequestValidator): def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, request, request_token=None, access_token=None): resource_owner_key = request_token if request_token else access_token - return not (client_key, nonce, timestamp, resource_owner_key) in self.nonces + return (client_key, nonce, timestamp, resource_owner_key) not in self.nonces def validate_client_key(self, client_key): return client_key in self.clients diff --git a/tests/oauth1/rfc5849/test_signatures.py b/tests/oauth1/rfc5849/test_signatures.py index 2d4735e..2c4ce3d 100644 --- a/tests/oauth1/rfc5849/test_signatures.py +++ b/tests/oauth1/rfc5849/test_signatures.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from jwt import InvalidKeyError from oauthlib.oauth1.rfc5849.signature import ( base_string_uri, collect_parameters, normalize_parameters, sign_hmac_sha1_with_client, sign_hmac_sha256_with_client, @@ -82,12 +83,13 @@ class SignatureTests(TestCase): # ==== Example test vector ======================================= - eg_signature_base_string =\ - 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' \ - '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' \ - 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' \ - 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' \ + eg_signature_base_string = ( + 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' + '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' + 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' + 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' '9d7dh3k39sjv7' + ) # The _signature base string_ above is copied from the end of # RFC 5849 section 3.4.1.1. @@ -101,11 +103,11 @@ class SignatureTests(TestCase): eg_base_string_uri = 'http://example.com/request' - eg_normalized_parameters =\ - 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' \ - 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' \ + eg_normalized_parameters = ( + 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' + 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' '&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7' - + ) # The above _normalized parameters_ corresponds to the parameters below. # # The parameters below is copied from the table at the end of @@ -133,12 +135,12 @@ class SignatureTests(TestCase): eg_body = 'c2&a3=2+q' - eg_authorization_header =\ - 'OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2",' \ - ' oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1",' \ - ' oauth_timestamp="137131201", oauth_nonce="7d8f3e4a",' \ + eg_authorization_header = ( + 'OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2",' + ' oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1",' + ' oauth_timestamp="137131201", oauth_nonce="7d8f3e4a",' ' oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D"' - + ) # ==== Signature base string calculating function tests ========== def test_signature_base_string(self): @@ -465,10 +467,10 @@ class SignatureTests(TestCase): expected_signature_hmac_sha256 = \ 'wdfdHUKXHbOnOGZP8WFAWMSAmWzN3EVBWWgXGlC/Eo4=' - expected_signature_hmac_sha512 = \ - 'u/vlyZFDxOWOZ9UUXwRBJHvq8/T4jCA74ocRmn2ECnjUBTAeJiZIRU8hDTjS88Tz' \ + expected_signature_hmac_sha512 = ( + 'u/vlyZFDxOWOZ9UUXwRBJHvq8/T4jCA74ocRmn2ECnjUBTAeJiZIRU8hDTjS88Tz' '1fGONffMpdZxUkUTW3k1kg==' - + ) def test_sign_hmac_sha1_with_client(self): """ Test sign and verify with HMAC-SHA1. @@ -632,21 +634,21 @@ GLYT3Jw1Lfb1bbuck9Y0JsRJO7uydWUbxXyZ+8YaDfE2NMw7sh2vAgMBAAE= # Note: the "echo -n" is needed to remove the last newline character, which # most text editors will add. - expected_signature_rsa_sha1 = \ - 'mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738' \ - 'HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q' \ + expected_signature_rsa_sha1 = ( + 'mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738' + 'HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q' 'yTR4TWCODlEfN7Zfgy8+pf+TjiXfIwRC1jEWbuL1E=' - - expected_signature_rsa_sha256 = \ - 'jqKl6m0WS69tiVJV8ZQ6aQEfJqISoZkiPBXRv6Al2+iFSaDpfeXjYm+Hbx6m1azR' \ - 'drZ/35PM3cvuid3LwW/siAkzb0xQcGnTyAPH8YcGWzmnKGY7LsB7fkqThchNxvRK' \ + ) + expected_signature_rsa_sha256 = ( + 'jqKl6m0WS69tiVJV8ZQ6aQEfJqISoZkiPBXRv6Al2+iFSaDpfeXjYm+Hbx6m1azR' + 'drZ/35PM3cvuid3LwW/siAkzb0xQcGnTyAPH8YcGWzmnKGY7LsB7fkqThchNxvRK' '/N7s9M1WMnfZZ+1dQbbwtTs1TG1+iexUcV7r3M7Heec=' - - expected_signature_rsa_sha512 = \ - 'jL1CnjlsNd25qoZVHZ2oJft47IRYTjpF5CvCUjL3LY0NTnbEeVhE4amWXUFBe9GL' \ - 'DWdUh/79ZWNOrCirBFIP26cHLApjYdt4ZG7EVK0/GubS2v8wT1QPRsog8zyiMZkm' \ + ) + expected_signature_rsa_sha512 = ( + 'jL1CnjlsNd25qoZVHZ2oJft47IRYTjpF5CvCUjL3LY0NTnbEeVhE4amWXUFBe9GL' + 'DWdUh/79ZWNOrCirBFIP26cHLApjYdt4ZG7EVK0/GubS2v8wT1QPRsog8zyiMZkm' 'g4JXdWCGXG8YRvRJTg+QKhXuXwS6TcMNakrgzgFIVhA=' - + ) def test_sign_rsa_sha1_with_client(self): """ Test sign and verify with RSA-SHA1. @@ -764,12 +766,17 @@ MmgDHR2tt8KeYTSgfU+BAkBcaVF91EQ7VXhvyABNYjeYP7lU7orOgdWMa/zbLXSU # Signing needs a private key - for bad_value in [None, '', 'foobar']: + for bad_value in [None, '']: self.assertRaises(ValueError, sign_rsa_sha1_with_client, self.eg_signature_base_string, MockClient(rsa_key=bad_value)) + self.assertRaises(InvalidKeyError, + sign_rsa_sha1_with_client, + self.eg_signature_base_string, + MockClient(rsa_key='foobar')) + self.assertRaises(AttributeError, sign_rsa_sha1_with_client, self.eg_signature_base_string, diff --git a/tests/oauth1/rfc5849/test_utils.py b/tests/oauth1/rfc5849/test_utils.py index 013c71a..2212890 100644 --- a/tests/oauth1/rfc5849/test_utils.py +++ b/tests/oauth1/rfc5849/test_utils.py @@ -53,11 +53,11 @@ class UtilsTests(TestCase): # The following is an isolated test function used to test the filter_params decorator. @filter_params def special_test_function(params, realm=None): - """ I am a special test function """ + """I am a special test function""" return 'OAuth ' + ','.join(['='.join([k, v]) for k, v in params]) # check that the docstring got through - self.assertEqual(special_test_function.__doc__, " I am a special test function ") + self.assertEqual(special_test_function.__doc__, "I am a special test function") # Check that the decorator filtering works as per design. # Any param that does not start with 'oauth' diff --git a/tests/oauth2/rfc6749/clients/test_base.py b/tests/oauth2/rfc6749/clients/test_base.py index 70a2283..b0970f2 100644 --- a/tests/oauth2/rfc6749/clients/test_base.py +++ b/tests/oauth2/rfc6749/clients/test_base.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import datetime +import json +from unittest.mock import patch from oauthlib import common from oauthlib.oauth2 import Client, InsecureTransportError, TokenExpiredError @@ -302,31 +304,6 @@ class ClientTest(TestCase): self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) self.assertFormBodyEqual(b, 'grant_type=refresh_token&scope={}&refresh_token={}'.format(scope, token)) - def test_parse_token_response_invalid_expires_at(self): - token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' - ' "token_type":"example",' - ' "expires_at":"2006-01-02T15:04:05Z",' - ' "scope":"/profile",' - ' "example_parameter":"example_value"}') - token = { - "access_token": "2YotnFZFEjr1zCsicMWpAA", - "token_type": "example", - "expires_at": "2006-01-02T15:04:05Z", - "scope": ["/profile"], - "example_parameter": "example_value" - } - - client = Client(self.client_id) - - # Parse code and state - response = client.parse_request_body_response(token_json, scope=["/profile"]) - self.assertEqual(response, token) - self.assertEqual(None, client._expires_at) - self.assertEqual(client.access_token, response.get("access_token")) - self.assertEqual(client.refresh_token, response.get("refresh_token")) - self.assertEqual(client.token_type, response.get("token_type")) - - def test_create_code_verifier_min_length(self): client = Client(self.client_id) length = 43 @@ -339,6 +316,12 @@ class ClientTest(TestCase): code_verifier = client.create_code_verifier(length=length) self.assertEqual(client.code_verifier, code_verifier) + def test_create_code_verifier_length(self): + client = Client(self.client_id) + length = 96 + code_verifier = client.create_code_verifier(length=length) + self.assertEqual(len(code_verifier), length) + def test_create_code_challenge_plain(self): client = Client(self.client_id) code_verifier = client.create_code_verifier(length=128) @@ -353,3 +336,43 @@ class ClientTest(TestCase): code_verifier = client.create_code_verifier(length=128) code_challenge_s256 = client.create_code_challenge(code_verifier=code_verifier, code_challenge_method='S256') self.assertEqual(code_challenge_s256, client.code_challenge) + + def test_parse_token_response_expires_at_types(self): + for title, fieldjson, expected, generated in [ + ('int', 1661185148, 1661185148, 1661185148), + ('float', 1661185148.6437678, 1661185148.6437678, 1661185148.6437678), + ('str', "\"2006-01-02T15:04:05Z\"", "2006-01-02T15:04:05Z", None), + ('str-as-int', "\"1661185148\"", 1661185148, 1661185148), + ('str-as-float', "\"1661185148.42\"", 1661185148.42, 1661185148.42), + ]: + with self.subTest(msg=title): + token_json = ('{{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_at":{expires_at},' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}}'.format(expires_at=fieldjson)) + + client = Client(self.client_id) + response = client.parse_request_body_response(token_json, scope=["/profile"]) + + self.assertEqual(response['expires_at'], expected, "response attribute wrong") + self.assertEqual(client.expires_at, expected, "client attribute wrong") + if generated: + self.assertEqual(client._expires_at, generated, "internal expiration wrong") + + @patch('time.time') + def test_parse_token_response_generated_expires_at_is_int(self, t): + t.return_value = 1661185148.6437678 + expected_expires_at = round(t.return_value) + 3600 + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_in":3600,' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}') + + client = Client(self.client_id) + + response = client.parse_request_body_response(token_json, scope=["/profile"]) + + self.assertEqual(response['expires_at'], expected_expires_at) + self.assertEqual(client._expires_at, expected_expires_at) diff --git a/tests/oauth2/rfc6749/clients/test_service_application.py b/tests/oauth2/rfc6749/clients/test_service_application.py index b97d855..84361d8 100644 --- a/tests/oauth2/rfc6749/clients/test_service_application.py +++ b/tests/oauth2/rfc6749/clients/test_service_application.py @@ -166,7 +166,7 @@ mfvGGg3xNjTMO7IdrwIDAQAB @patch('time.time') def test_parse_token_response(self, t): t.return_value = time() - self.token['expires_at'] = self.token['expires_in'] + t.return_value + self.token['expires_at'] = self.token['expires_in'] + round(t.return_value) client = ServiceApplicationClient(self.client_id) diff --git a/tests/oauth2/rfc6749/clients/test_web_application.py b/tests/oauth2/rfc6749/clients/test_web_application.py index 7a71121..f9a4c9d 100644 --- a/tests/oauth2/rfc6749/clients/test_web_application.py +++ b/tests/oauth2/rfc6749/clients/test_web_application.py @@ -252,18 +252,34 @@ class WebApplicationClientTest(TestCase): self.assertEqual(r4b_params['client_id'], self.client_id) # scenario Warnings - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") # catch all - - # warning1 - raise a DeprecationWarning if a `client_id` is submitted - rWarnings1 = client.prepare_request_body(client_id=self.client_id) - self.assertEqual(len(w), 1) - self.assertIsInstance(w[0].message, DeprecationWarning) - + # warning1 - raise a DeprecationWarning if a `client_id` is submitted + with self.assertWarns(DeprecationWarning): + client.prepare_request_body(client_id=self.client_id) # testing the exact warning message in Python2&Python3 is a pain # scenario Exceptions # exception1 - raise a ValueError if the a different `client_id` is submitted - with self.assertRaises(ValueError) as cm: + with self.assertWarns(DeprecationWarning), self.assertRaises(ValueError): client.prepare_request_body(client_id='different_client_id') # testing the exact exception message in Python2&Python3 is a pain + + def test_expires_in_as_str(self): + """ + see regression issue #906 + """ + + client = WebApplicationClient( + client_id="dummy", + token={"access_token": "xyz", "expires_in": "3600"} + ) + self.assertIsNotNone(client) + client = WebApplicationClient( + client_id="dummy", + token={"access_token": "xyz", "expires_in": 3600} + ) + self.assertIsNotNone(client) + client = WebApplicationClient( + client_id="dummy", + token={"access_token": "xyz", "expires_in": 3600.12} + ) + self.assertIsNotNone(client) diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index 1f5b912..c36d94b 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -20,8 +20,8 @@ class MetadataEndpointTest(TestCase): "introspection_endpoint": "https://foo.bar/introspect", "token_endpoint": "https://foo.bar/token" } - from oauthlib.oauth2 import Server as OAuth2Server - from oauthlib.openid import Server as OpenIDServer + from oauthlib.oauth2 import Server as OAuth2Server # noqa: PLC0415 + from oauthlib.openid import Server as OpenIDServer # noqa: PLC0415 endpoint = OAuth2Server(None) metadata = MetadataEndpoint([endpoint], default_claims) @@ -98,6 +98,7 @@ class MetadataEndpointTest(TestCase): "scopes_supported": ["email", "profile"], "grant_types_supported": [ "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", "password", "client_credentials", "refresh_token", @@ -130,8 +131,8 @@ class MetadataEndpointTest(TestCase): } def sort_list(claims): - for k in claims.keys(): - claims[k] = sorted(claims[k]) + for key, value in claims.items(): + claims[key] = sorted(value) sort_list(metadata.claims) sort_list(expected_claims) diff --git a/tests/oauth2/rfc6749/grant_types/test_refresh_token.py b/tests/oauth2/rfc6749/grant_types/test_refresh_token.py index 581f2a4..f963444 100644 --- a/tests/oauth2/rfc6749/grant_types/test_refresh_token.py +++ b/tests/oauth2/rfc6749/grant_types/test_refresh_token.py @@ -130,6 +130,22 @@ class RefreshTokenGrantTest(TestCase): self.request) self.mock_validator.client_authentication_required.assert_called_once_with(self.request) + + def test_authentication_required_populate_client_id(self): + """ + Make sure that request.client_id is populated from + request.client.client_id if None. + + """ + self.mock_validator.client_authentication_required.return_value = True + self.mock_validator.authenticate_client.return_value = True + # self.mock_validator.authenticate_client_id.return_value = False + # self.request.code = 'waffles' + self.request.client_id = None + self.request.client.client_id = 'foobar' + self.auth.validate_token_request(self.request) + self.request.client_id = 'foobar' + def test_invalid_grant_type(self): self.request.grant_type = 'wrong_type' self.assertRaises(errors.UnsupportedGrantTypeError, @@ -168,7 +184,7 @@ class RefreshTokenGrantTest(TestCase): # all ok but without request.scope del self.request.scope self.auth.validate_token_request(self.request) - self.assertEqual(self.request.scopes, 'foo bar baz'.split()) + self.assertEqual(self.request.scopes, ['foo', 'bar', 'baz']) # CORS diff --git a/tests/oauth2/rfc6749/test_parameters.py b/tests/oauth2/rfc6749/test_parameters.py index cd8c9e9..bd8a8b6 100644 --- a/tests/oauth2/rfc6749/test_parameters.py +++ b/tests/oauth2/rfc6749/test_parameters.py @@ -302,3 +302,30 @@ class ParameterTests(TestCase): finally: signals.scope_changed.disconnect(record_scope_change) del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] + + + def test_parse_expires(self): + for title, arg, expected in [ + ('none', (None, None), (None, None, None)), + ('expires_in only', (3600, None), (3600, 4600, 4600)), + ('expires_in and expires_at', (3600, 200), (3600, 200, 200)), + ('expires_in and expires_at float', (3600, 200.42), (3600, 200.42, 200.42)), + ('expires_in and expires_at str-int', (3600, "200"), (3600, 200, 200)), + ('expires_in and expires_at str-float', (3600, "200.42"), (3600, 200.42, 200.42)), + ('expires_in float only', (3600.12, None), (3600, 4600, 4600)), + ('expires_in float and expires_at', (3600.12, 200), (3600, 200, 200)), + ('expires_in float and expires_at float', (3600.12, 200.42), (3600, 200.42, 200.42)), + ('expires_in float and expires_at str-int', (3600.12, "200"), (3600, 200, 200)), + ('expires_in float and expires_at str-float', (3600.12, "200.42"), (3600, 200.42, 200.42)), + ('expires_in str only', ("3600", None), (3600, 4600, 4600)), + ('expires_in str and expires_at', ("3600", 200), (3600, 200, 200)), + ('expires_in str and expires_at float', ("3600", 200.42), (3600, 200.42, 200.42)), + ('expires_in str and expires_at str-int', ("3600", "200"), (3600, 200, 200)), + ('expires_in str and expires_at str-float', ("3600", "200.42"), (3600, 200.42, 200.42)), + ]: + with self.subTest(msg=title): + params = { + "expires_in": arg[0], + "expires_at": arg[1] + } + self.assertEqual(expected, parse_expires(params)) diff --git a/tests/oauth2/rfc6749/test_tokens.py b/tests/oauth2/rfc6749/test_tokens.py index fa6b1c0..ec8efca 100644 --- a/tests/oauth2/rfc6749/test_tokens.py +++ b/tests/oauth2/rfc6749/test_tokens.py @@ -76,7 +76,7 @@ class TokenTest(TestCase): bearer_uri = 'http://server.example.com/resource?access_token=vF9dft4qmT' def _mocked_validate_bearer_token(self, token, scopes, request): - if not token: + if not token: # noqa: SIM103 return False return True diff --git a/tests/oauth2/rfc6749/test_utils.py b/tests/oauth2/rfc6749/test_utils.py index 3299591..8417fe5 100644 --- a/tests/oauth2/rfc6749/test_utils.py +++ b/tests/oauth2/rfc6749/test_utils.py @@ -78,7 +78,7 @@ class UtilsTests(TestCase): for x in string_list: assert x in set_scope - self.assertRaises(ValueError, list_to_scope, object()) + self.assertRaises(ValueError, list_to_scope, object()) def test_scope_to_list(self): expected = ['foo', 'bar', 'baz'] diff --git a/tests/oauth2/rfc8628/endpoints/__init__.py b/tests/oauth2/rfc8628/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/oauth2/rfc8628/endpoints/test_device_application_server.py b/tests/oauth2/rfc8628/endpoints/test_device_application_server.py new file mode 100644 index 0000000..f043675 --- /dev/null +++ b/tests/oauth2/rfc8628/endpoints/test_device_application_server.py @@ -0,0 +1,26 @@ +import json +from unittest import TestCase, mock + +from oauthlib.common import Request, urlencode +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc8628.endpoints.pre_configured import DeviceApplicationServer +from oauthlib.oauth2.rfc8628.request_validator import RequestValidator + + +def test_server_set_up_device_endpoint_instance_attributes_correctly(): + """ + Simple test that just instantiates DeviceApplicationServer + and asserts the important attributes are present + """ + validator = mock.MagicMock(spec=RequestValidator) + validator.get_default_redirect_uri.return_value = None + validator.get_code_challenge.return_value = None + + verification_uri = "test.com/device" + verification_uri_complete = "test.com/device?user_code=123" + device = DeviceApplicationServer(validator, verification_uri=verification_uri, verification_uri_complete=verification_uri_complete) + device_vars = vars(device) + assert device_vars["_verification_uri_complete"] == "test.com/device?user_code=123" + assert device_vars["_verification_uri"] == "test.com/device" + assert device_vars["_expires_in"] == 1800 + assert device_vars["_interval"] == 5 diff --git a/tests/oauth2/rfc8628/endpoints/test_error_responses.py b/tests/oauth2/rfc8628/endpoints/test_error_responses.py new file mode 100644 index 0000000..c799cc8 --- /dev/null +++ b/tests/oauth2/rfc8628/endpoints/test_error_responses.py @@ -0,0 +1,95 @@ +import json +from unittest import TestCase, mock + +from oauthlib.common import Request, urlencode +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc8628.endpoints.pre_configured import DeviceApplicationServer +from oauthlib.oauth2.rfc8628.request_validator import RequestValidator + + +class ErrorResponseTest(TestCase): + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = "mocked" + return True + + def build_request(self, uri="https://example.com/device_authorize", client_id="foo"): + body = "" + if client_id: + body = f"client_id={client_id}" + return Request( + uri, + http_method="POST", + body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + def assert_request_raises(self, error, request): + """Test that the request fails similarly on the validation and response endpoint.""" + self.assertRaises( + error, + self.device.validate_device_authorization_request, + request, + ) + self.assertRaises( + error, + self.device.create_device_authorization_response, + uri=request.uri, + http_method=request.http_method, + body=request.body, + headers=request.headers, + ) + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = None + self.validator.get_code_challenge.return_value = None + self.device = DeviceApplicationServer(self.validator, "https://example.com/verify") + + def test_missing_client_id(self): + # Device code grant + request = self.build_request(client_id=None) + self.assert_request_raises(errors.MissingClientIdError, request) + + def test_empty_client_id(self): + # Device code grant + self.assertRaises( + errors.MissingClientIdError, + self.device.create_device_authorization_response, + "https://i.l/", + "POST", + "client_id=", + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + + def test_invalid_client_id(self): + request = self.build_request(client_id="foo") + # Device code grant + self.validator.validate_client_id.return_value = False + self.assert_request_raises(errors.InvalidClientIdError, request) + + def test_duplicate_client_id(self): + request = self.build_request() + request.body = "client_id=foo&client_id=bar" + # Device code grant + self.validator.validate_client_id.return_value = False + self.assert_request_raises(errors.InvalidRequestFatalError, request) + + def test_unauthenticated_confidential_client(self): + self.validator.client_authentication_required.return_value = True + self.validator.authenticate_client.return_value = False + request = self.build_request() + self.assert_request_raises(errors.InvalidClientError, request) + + def test_unauthenticated_public_client(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = False + request = self.build_request() + self.assert_request_raises(errors.InvalidClientError, request) + + def test_duplicate_scope_parameter(self): + request = self.build_request() + request.body = "client_id=foo&scope=foo&scope=bar" + # Device code grant + self.validator.validate_client_id.return_value = False + self.assert_request_raises(errors.InvalidRequestFatalError, request) diff --git a/tests/oauth2/rfc8628/grant_types/__init__.py b/tests/oauth2/rfc8628/grant_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/oauth2/rfc8628/grant_types/test_device_code.py b/tests/oauth2/rfc8628/grant_types/test_device_code.py new file mode 100644 index 0000000..da0592f --- /dev/null +++ b/tests/oauth2/rfc8628/grant_types/test_device_code.py @@ -0,0 +1,172 @@ +import json +from unittest import mock +import pytest + +from oauthlib import common + +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +def create_request(body: str = "") -> common.Request: + request = common.Request("http://a.b/path", body=body or None) + request.scopes = ("hello", "world") + request.expires_in = 1800 + request.client = "batman" + request.client_id = "abcdef" + request.code = "1234" + request.response_type = "code" + request.grant_type = "urn:ietf:params:oauth:grant-type:device_code" + request.redirect_uri = "https://a.b/" + return request + + +def create_device_code_grant(mock_validator: mock.MagicMock) -> DeviceCodeGrant: + return DeviceCodeGrant(request_validator=mock_validator) + + +def test_custom_auth_validators_unsupported(): + custom_validator = mock.Mock() + validator = mock.MagicMock() + + expected = ( + "DeviceCodeGrant does not " + "support authorization validators. Use token validators instead." + ) + with pytest.raises(ValueError, match=expected): + DeviceCodeGrant(validator, pre_auth=[custom_validator]) + + with pytest.raises(ValueError, match=expected): + DeviceCodeGrant(validator, post_auth=[custom_validator]) + + expected = "'tuple' object has no attribute 'append'" + auth = DeviceCodeGrant(validator) + with pytest.raises(AttributeError, match=expected): + auth.custom_validators.pre_auth.append(custom_validator) + + +def test_custom_pre_and_post_token_validators(): + client = mock.MagicMock() + + validator = mock.MagicMock() + pre_token_validator = mock.Mock() + post_token_validator = mock.Mock() + + request: common.Request = create_request() + request.client = client + + auth = DeviceCodeGrant(validator) + + auth.custom_validators.pre_token.append(pre_token_validator) + auth.custom_validators.post_token.append(post_token_validator) + + bearer = BearerToken(validator) + auth.create_token_response(request, bearer) + + pre_token_validator.assert_called() + post_token_validator.assert_called() + + +def test_create_token_response(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + token = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + + # when a custom token generator callable isn't used + # the random generator is used as default for the access token + assert token == { + "access_token": mock.ANY, + "expires_in": 3600, + "token_type": "Bearer", + "scope": "hello world", + "refresh_token": mock.ANY, + } + + assert status_code == 200 + + validator.save_token.assert_called_once() + + +def test_invalid_client_error(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + validator.authenticate_client.return_value = False + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + "WWW-Authenticate": 'Bearer error="invalid_client"', + } + assert body == {"error": "invalid_client"} + assert status_code == 401 + + validator.save_token.assert_not_called() + + +def test_invalid_grant_type_error(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + request.grant_type = "not_device_code" + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + assert body == {"error": "unsupported_grant_type"} + assert status_code == 400 + + validator.save_token.assert_not_called() + + +def test_duplicate_params_error(): + validator = mock.MagicMock() + request: common.Request = create_request( + "client_id=123&scope=openid&scope=openid" + ) + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + assert body == {"error": "invalid_request", "error_description": "Duplicate scope parameter."} + assert status_code == 400 + + validator.save_token.assert_not_called() diff --git a/tests/oauth2/rfc8628/test_server.py b/tests/oauth2/rfc8628/test_server.py new file mode 100644 index 0000000..5202503 --- /dev/null +++ b/tests/oauth2/rfc8628/test_server.py @@ -0,0 +1,113 @@ +import json +from unittest import mock + +from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint +from oauthlib.oauth2.rfc8628.request_validator import RequestValidator + +from tests.unittest import TestCase + + +class DeviceAuthorizationEndpointTest(TestCase): + def _configure_endpoint( + self, interval=None, verification_uri_complete=None, user_code_generator=None + ): + self.endpoint = DeviceAuthorizationEndpoint( + request_validator=mock.MagicMock(spec=RequestValidator), + verification_uri=self.verification_uri, + interval=interval, + verification_uri_complete=verification_uri_complete, + user_code_generator=user_code_generator, + ) + + def setUp(self): + self.request_validator = mock.MagicMock(spec=RequestValidator) + self.verification_uri = "http://i.b/l/verify" + self.uri = "http://i.b/l" + self.http_method = "POST" + self.body = "client_id=abc" + self.headers = {"Content-Type": "application/x-www-form-urlencoded"} + self._configure_endpoint() + + def response_payload(self): + return self.uri, self.http_method, self.body, self.headers + + @mock.patch("oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token") + def test_device_authorization_grant(self, generate_token): + generate_token.side_effect = ["abc", "def"] + _, body, status_code = self.endpoint.create_device_authorization_response( + *self.response_payload() + ) + expected_payload = { + "verification_uri": "http://i.b/l/verify", + "user_code": "abc", + "device_code": "def", + "expires_in": 1800, + } + self.assertEqual(200, status_code) + self.assertEqual(body, expected_payload) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_interval(self): + self._configure_endpoint(interval=5) + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual(5, body["interval"]) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_interval_with_zero(self): + self._configure_endpoint(interval=0) + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual(0, body["interval"]) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_verify_url_complete_string(self): + self._configure_endpoint(verification_uri_complete="http://i.l/v?user_code={user_code}") + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual( + "http://i.l/v?user_code=abc", + body["verification_uri_complete"], + ) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_verify_url_complete_callable(self): + self._configure_endpoint(verification_uri_complete=lambda u: f"http://i.l/v?user_code={u}") + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual( + "http://i.l/v?user_code=abc", + body["verification_uri_complete"], + ) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_user_gode_generator(self): + def user_code(): + """ + A friendly user code the device can display and the user + can type in. It's up to the device how + this code should be displayed. e.g 123-456 + """ + return "123456" + + self._configure_endpoint( + verification_uri_complete=lambda u: f"http://i.l/v?user_code={u}", + user_code_generator=user_code, + ) + + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual( + "http://i.l/v?user_code=123456", + body["verification_uri_complete"], + ) diff --git a/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py b/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py index c55136f..5b04edf 100644 --- a/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py +++ b/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py @@ -28,7 +28,8 @@ class OpenIDConnectEndpointTest(TestCase): 'redirect_uri': 'https://a.b/cb', 'response_type': 'code', 'client_id': 'abcdef', - 'scope': 'hello openid' + 'scope': 'hello openid', + 'ui_locales': 'en-US' } self.url = 'http://a.b/path?' + urlencode(params) @@ -76,3 +77,4 @@ class OpenIDConnectEndpointTest(TestCase): self.assertEqual(creds['prompt'], {'consent'}) self.assertEqual(creds['nonce'], 'abcd') self.assertEqual(creds['display'], 'touch') + self.assertEqual(creds['ui_locales'], ['en-US']) diff --git a/tests/openid/connect/core/endpoints/test_refresh_token.py b/tests/openid/connect/core/endpoints/test_refresh_token.py new file mode 100644 index 0000000..9161f5a --- /dev/null +++ b/tests/openid/connect/core/endpoints/test_refresh_token.py @@ -0,0 +1,32 @@ +"""Ensure that the server correctly uses the OIDC flavor of +the Refresh token grant type when appropriate. + +When the OpenID scope is provided, the refresh token response +should include a fresh ID token. +""" +import json +from unittest import mock + +from oauthlib.openid import RequestValidator +from oauthlib.openid.connect.core.endpoints.pre_configured import Server + +from tests.unittest import TestCase + + +class TestRefreshToken(TestCase): + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_id_token.return_value='id_token' + + self.server = Server(self.validator) + + def test_refresh_token_with_openid(self): + request_body = 'scope=openid+test_scope&grant_type=refresh_token&refresh_token=abc' + headers, body, status = self.server.create_token_response('', body=request_body) + self.assertIn('id_token', json.loads(body)) + + def test_refresh_token_no_openid(self): + request_body = 'scope=test_scope&grant_type=refresh_token&refresh_token=abc' + headers, body, status = self.server.create_token_response('', body=request_body) + self.assertNotIn('id_token', json.loads(body)) diff --git a/tests/test_uri_validate.py b/tests/test_uri_validate.py index 6a9f8ea..f1ac404 100644 --- a/tests/test_uri_validate.py +++ b/tests/test_uri_validate.py @@ -1,4 +1,6 @@ +from datetime import datetime import unittest + from oauthlib.uri_validate import is_absolute_uri from tests.unittest import TestCase @@ -76,7 +78,6 @@ class UriValidateTest(TestCase): self.assertIsNone(is_absolute_uri('http://[abcd:efgh::1]/')) def test_recursive_regex(self): - from datetime import datetime t0 = datetime.now() is_absolute_uri('http://[::::::::::::::::::::::::::]/path') t1 = datetime.now()