diff options
38 files changed, 739 insertions, 38 deletions
diff --git a/.coveragerc b/.coveragerc index 70666c7..73f1418 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,6 @@ [run] branch = 1 cover_pylib = 0 -include=*oauthlib/* omit = oauthlib.tests.* [report] diff --git a/.travis.yml b/.travis.yml index e0e543d..6b195b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,26 @@ language: python python: 3.8 +os: linux dist: bionic cache: pip -matrix: +jobs: include: - - python: 3.6 + - python: "3.6" env: TOXENV=py36 - - python: 3.7 + - python: "3.7" env: TOXENV=py37 - - python: 3.8 + - python: "3.8" env: TOXENV=py38,bandit,docs,readme - - python: pypy3 + - python: "3.9" + env: TOXENV=py39 + - python: "3.10.2" + env: TOXENV=py310 + - python: "3.11-dev" + env: TOXENV=py311 + - python: "pypy3" env: TOXENV=pypy3 + allow_failures: + - python: "3.11-dev" before_install: - python -m pip install --upgrade pip setuptools - python -m pip install tox coveralls @@ -28,7 +37,7 @@ notifications: on_start: never deploy: - provider: releases - api_key: + token: secure: "eqEWOzKWZCuvd1a77CA03OX/HCrsYlsu1/Sz/RhXQIEhKz6tKp10KGw9zr57bHAIl0OfJFK9k63lI2HOctAmwkKeeQ4HdNqw4pHFa8Gk3liGp31KSmshVtHX8Rtn0DuFA028Wm7w5n+fOVc8tJVU/UsKjsfsAzRHnQjMamckoXU=" skip_cleanup: true on: @@ -37,7 +46,7 @@ deploy: condition: $TOXENV = py36 repo: oauthlib/oauthlib - provider: pypi - user: JonathanHuot + username: JonathanHuot password: secure: "OozNM16flVLvqDoNzmoTENchhS1w0/dEJZvXBQK2KWmh8fyGj2UZus1vkl6bA5V3Yu9MZLYFpDcltl/qraY3Up6iXQpwKz4q+ICygAudYM2kJ5l8ZEe+wy2FikWbD6LkXf5uKIJJnPNSC8AI86ZyxM/XZxbYjj/+jXyJ1YFZwwQ=" distributions: sdist bdist_wheel diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4dae8d3..c67f4da 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,9 @@ OAuth2.0 Provider - Bugfixes * #753: Fix acceptance of valid IPv6 addresses in URI validation +OAuth2.0 Provider - Features + * #751: OIDC add support of refreshing ID Tokens + OAuth2.0 Client - Bugfixes * #730: Base OAuth2 Client now has a consistent way of managing the `scope`: it consistently @@ -31,6 +34,8 @@ OAuth2.0 Provider - Bugfixes * #746: OpenID Connect Hybrid - fix nonce not passed to add_id_token * #756: Different prompt values are now handled according to spec (e.g. prompt=none) * #759: OpenID Connect - fix Authorization: Basic parsing + * #751: The RefreshTokenGrant modifiers now take the same arguments as the + AuthorizationCodeGrant modifiers (`token`, `token_handler`, `request`). General * #716: improved skeleton validator for public vs private client diff --git a/docs/installation.rst b/docs/installation.rst index 0e00e39..61428ce 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -118,7 +118,7 @@ Install from GitHub ------------------- Alternatively, install it directly from the source repository on -GitHub. This is the "bleading edge" version, but it may be useful for +GitHub. This is the "bleeding edge" version, but it may be useful for accessing bug fixes and/or new features that have not been released. Standard install diff --git a/docs/oauth2/clients/deviceclient.rst b/docs/oauth2/clients/deviceclient.rst new file mode 100644 index 0000000..d4e8d7d --- /dev/null +++ b/docs/oauth2/clients/deviceclient.rst @@ -0,0 +1,5 @@ +DeviceClient +------------------------ + +.. autoclass:: oauthlib.oauth2.DeviceClient + :members: diff --git a/docs/oauth2/oidc/refresh_token.rst b/docs/oauth2/oidc/refresh_token.rst new file mode 100644 index 0000000..01d2d7f --- /dev/null +++ b/docs/oauth2/oidc/refresh_token.rst @@ -0,0 +1,6 @@ +OpenID Authorization Code +------------------------- + +.. autoclass:: oauthlib.openid.connect.core.grant_types.RefreshTokenGrant + :members: + :inherited-members: diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index a6e1ccc..deefb1a 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -33,3 +33,4 @@ from .rfc6749.grant_types import ( from .rfc6749.request_validator import RequestValidator from .rfc6749.tokens import BearerToken, OAuth2Token from .rfc6749.utils import is_secure_transport +from .rfc8628.clients import DeviceClient diff --git a/oauthlib/oauth2/rfc6749/clients/base.py b/oauthlib/oauth2/rfc6749/clients/base.py index 88065ab..bb4c133 100644 --- a/oauthlib/oauth2/rfc6749/clients/base.py +++ b/oauthlib/oauth2/rfc6749/clients/base.py @@ -8,6 +8,10 @@ for consuming OAuth 2.0 RFC6749. """ import time import warnings +import secrets +import re +import hashlib +import base64 from oauthlib.common import generate_token from oauthlib.oauth2.rfc6749 import tokens @@ -61,6 +65,9 @@ class Client: state=None, redirect_url=None, state_generator=generate_token, + code_verifier=None, + code_challenge=None, + code_challenge_method=None, **kwargs): """Initialize a client with commonly used attributes. @@ -99,6 +106,15 @@ class Client: :param state_generator: A no argument state generation callable. Defaults to :py:meth:`oauthlib.common.generate_token`. + + :param code_verifier: PKCE parameter. A cryptographically random string that is used to correlate the + authorization request to the token request. + + :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 code challenge. + Defaults to "plain" if not present in the request. """ self.client_id = client_id @@ -113,6 +129,9 @@ class Client: self.state_generator = state_generator self.state = state self.redirect_url = redirect_url + self.code_verifier = code_verifier + self.code_challenge = code_challenge + self.code_challenge_method = code_challenge_method self.code = None self.expires_in = None self._expires_at = None @@ -471,6 +490,91 @@ class Client: raise ValueError("Invalid token placement.") return uri, headers, body + def create_code_verifier(self, length): + """Create PKCE **code_verifier** used in computing **code_challenge**. + + :param length: REQUIRED. The length of the code_verifier. + + The client first creates a code verifier, "code_verifier", for each + OAuth 2.0 [RFC6749] Authorization Request, in the following manner: + + code_verifier = high-entropy cryptographic random STRING using the + unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + from Section 2.3 of [RFC3986], with a minimum length of 43 characters + and a maximum length of 128 characters. + + .. _`Section 4.1`: https://tools.ietf.org/html/rfc7636#section-4.1 + """ + code_verifier = None + + if not length >= 43: + raise ValueError("Length must be greater than or equal to 43") + + 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") + + self.code_verifier = code_verifier + + return code_verifier + + def create_code_challenge(self, code_verifier, code_challenge_method=None): + """Create PKCE **code_challenge** derived from the **code_verifier**. + + :param code_verifier: REQUIRED. The **code_verifier** generated from create_code_verifier(). + :param code_challenge_method: OPTIONAL. The method used to derive the **code_challenge**. Acceptable + values include "S256". DEFAULT is "plain". + + + The client then creates a code challenge derived from the code + verifier by using one of the following transformations on the code + verifier: + + plain + code_challenge = code_verifier + + S256 + code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + + If the client is capable of using "S256", it MUST use "S256", as + "S256" is Mandatory To Implement (MTI) on the server. Clients are + permitted to use "plain" only if they cannot support "S256" for some + technical reason and know via out-of-band configuration that the + server supports "plain". + + The plain transformation is for compatibility with existing + deployments and for constrained environments that can't use the S256 + transformation. + + .. _`Section 4.2`: https://tools.ietf.org/html/rfc7636#section-4.2 + """ + code_challenge = None + + if code_verifier == None: + raise ValueError("Invalid code_verifier") + + if code_challenge_method == None: + code_challenge_method = "plain" + self.code_challenge_method = code_challenge_method + code_challenge = code_verifier + self.code_challenge = code_challenge + + if code_challenge_method == "S256": + h = hashlib.sha256() + h.update(code_verifier.encode(encoding='ascii')) + sha256_val = h.digest() + code_challenge = bytes.decode(base64.urlsafe_b64encode(sha256_val)) + # replace '+' with '-', '/' with '_', and remove trailing '=' + code_challenge = code_challenge.replace("+", "-").replace("/", "_").replace("=", "") + self.code_challenge = code_challenge + + return code_challenge + def _add_mac_token(self, uri, http_method='GET', body=None, headers=None, token_placement=AUTH_HEADER, ext=None, **kwargs): """Add a MAC token to the request authorization header. @@ -513,7 +617,10 @@ class Client: self._expires_at = time.time() + int(self.expires_in) if 'expires_at' in response: - self._expires_at = int(response.get('expires_at')) + try: + self._expires_at = int(response.get('expires_at')) + except: + self._expires_at = None if 'mac_key' in response: self.mac_key = response.get('mac_key') diff --git a/oauthlib/oauth2/rfc6749/clients/web_application.py b/oauthlib/oauth2/rfc6749/clients/web_application.py index a1f3db1..1d3b2b5 100644 --- a/oauthlib/oauth2/rfc6749/clients/web_application.py +++ b/oauthlib/oauth2/rfc6749/clients/web_application.py @@ -41,7 +41,7 @@ class WebApplicationClient(Client): self.code = code def prepare_request_uri(self, uri, redirect_uri=None, scope=None, - state=None, **kwargs): + state=None, code_challenge=None, code_challenge_method='plain', **kwargs): """Prepare the authorization code request URI The client constructs the request URI by adding the following @@ -62,6 +62,13 @@ 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 + authorization request, to be verified against later. + + :param code_challenge_method: OPTIONAL. PKCE parameter. A method that was used to derive code challenge. + Defaults to "plain" if not present in the request. + :param kwargs: Extra arguments to include in the request URI. In addition to supplied parameters, OAuthLib will append the ``client_id`` @@ -76,6 +83,10 @@ class WebApplicationClient(Client): 'https://example.com?client_id=your_id&response_type=code&redirect_uri=https%3A%2F%2Fa.b%2Fcallback' >>> client.prepare_request_uri('https://example.com', scope=['profile', 'pictures']) 'https://example.com?client_id=your_id&response_type=code&scope=profile+pictures' + >>> client.prepare_request_uri('https://example.com', code_challenge='kjasBS523KdkAILD2k78NdcJSk2k3KHG6') + 'https://example.com?client_id=your_id&response_type=code&code_challenge=kjasBS523KdkAILD2k78NdcJSk2k3KHG6' + >>> client.prepare_request_uri('https://example.com', code_challenge_method='S256') + 'https://example.com?client_id=your_id&response_type=code&code_challenge_method=S256' >>> client.prepare_request_uri('https://example.com', foo='bar') 'https://example.com?client_id=your_id&response_type=code&foo=bar' @@ -87,10 +98,11 @@ class WebApplicationClient(Client): """ scope = self.scope if scope is None else scope return prepare_grant_uri(uri, self.client_id, 'code', - redirect_uri=redirect_uri, scope=scope, state=state, **kwargs) + redirect_uri=redirect_uri, scope=scope, state=state, code_challenge=code_challenge, + code_challenge_method=code_challenge_method, **kwargs) def prepare_request_body(self, code=None, redirect_uri=None, body='', - include_client_id=True, **kwargs): + include_client_id=True, code_verifier=None, **kwargs): """Prepare the access token request body. The client makes a request to the token endpoint by adding the @@ -113,6 +125,9 @@ class WebApplicationClient(Client): authorization server as described in `Section 3.2.1`_. :type include_client_id: Boolean + :param code_verifier: OPTIONAL. A cryptographically random string that is used to correlate the + authorization request to the token request. + :param kwargs: Extra parameters to include in the token request. In addition OAuthLib will add the ``grant_type`` parameter set to @@ -127,6 +142,8 @@ class WebApplicationClient(Client): >>> client = WebApplicationClient('your_id') >>> client.prepare_request_body(code='sh35ksdf09sf') 'grant_type=authorization_code&code=sh35ksdf09sf' + >>> client.prepare_request_body(code_verifier='KB46DCKJ873NCGXK5GD682NHDKK34GR') + 'grant_type=authorization_code&code_verifier=KB46DCKJ873NCGXK5GD682NHDKK34GR' >>> client.prepare_request_body(code='sh35ksdf09sf', foo='bar') 'grant_type=authorization_code&code=sh35ksdf09sf&foo=bar' @@ -154,7 +171,7 @@ class WebApplicationClient(Client): kwargs['client_id'] = self.client_id kwargs['include_client_id'] = include_client_id return prepare_token_request(self.grant_type, code=code, body=body, - redirect_uri=redirect_uri, **kwargs) + redirect_uri=redirect_uri, code_verifier=code_verifier, **kwargs) def parse_request_uri_response(self, uri, state=None): """Parse the URI query for code and state. diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 81ee1de..d43a824 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -54,7 +54,8 @@ class MetadataEndpoint(BaseEndpoint): """Create metadata response """ headers = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', } return headers, json.dumps(self.claims), 200 diff --git a/oauthlib/oauth2/rfc6749/errors.py b/oauthlib/oauth2/rfc6749/errors.py index b01e247..da24fea 100644 --- a/oauthlib/oauth2/rfc6749/errors.py +++ b/oauthlib/oauth2/rfc6749/errors.py @@ -103,15 +103,12 @@ class OAuth2Error(Exception): value "Bearer". This scheme MUST be followed by one or more auth-param values. """ - authvalues = [ - "Bearer", - 'error="{}"'.format(self.error) - ] + authvalues = ['error="{}"'.format(self.error)] if self.description: authvalues.append('error_description="{}"'.format(self.description)) if self.uri: authvalues.append('error_uri="{}"'.format(self.uri)) - return {"WWW-Authenticate": ", ".join(authvalues)} + return {"WWW-Authenticate": "Bearer " + ", ".join(authvalues)} return {} diff --git a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py index bf42d88..b799823 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py +++ b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -10,6 +10,7 @@ import logging from oauthlib import common from .. import errors +from ..utils import is_secure_transport from .base import GrantTypeBase log = logging.getLogger(__name__) @@ -272,6 +273,8 @@ class AuthorizationCodeGrant(GrantTypeBase): grant = self.create_authorization_code(request) for modifier in self._code_modifiers: grant = modifier(grant, token_handler, request) + if 'access_token' in grant: + self.request_validator.save_token(grant, request) log.debug('Saving grant %r for %r.', grant, request) self.request_validator.save_authorization_code( request.client_id, grant, request) @@ -310,6 +313,7 @@ class AuthorizationCodeGrant(GrantTypeBase): self.request_validator.save_token(token, request) self.request_validator.invalidate_authorization_code( request.client_id, request.code, request) + headers.update(self._create_cors_headers(request)) return headers, json.dumps(token), 200 def validate_authorization_request(self, request): @@ -543,3 +547,20 @@ class AuthorizationCodeGrant(GrantTypeBase): if challenge_method in self._code_challenge_methods: return self._code_challenge_methods[challenge_method](verifier, challenge) raise NotImplementedError('Unknown challenge_method %s' % challenge_method) + + def _create_cors_headers(self, request): + """If CORS is allowed, create the appropriate headers.""" + if 'origin' not in request.headers: + return {} + + origin = request.headers['origin'] + if not is_secure_transport(origin): + log.debug('Origin "%s" is not HTTPS, CORS not allowed.', origin) + return {} + elif not self.request_validator.is_origin_allowed( + request.client_id, origin, request): + log.debug('Invalid origin "%s", CORS not allowed.', origin) + return {} + else: + log.debug('Valid origin "%s", injecting CORS headers.', origin) + return {'Access-Control-Allow-Origin': origin} diff --git a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py index 8698a3d..f801de4 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py +++ b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -63,7 +63,7 @@ class RefreshTokenGrant(GrantTypeBase): refresh_token=self.issue_new_refresh_tokens) for modifier in self._token_modifiers: - token = modifier(token) + token = modifier(token, token_handler, request) self.request_validator.save_token(token, request) diff --git a/oauthlib/oauth2/rfc6749/parameters.py b/oauthlib/oauth2/rfc6749/parameters.py index f07b8bd..44738bb 100644 --- a/oauthlib/oauth2/rfc6749/parameters.py +++ b/oauthlib/oauth2/rfc6749/parameters.py @@ -23,7 +23,7 @@ from .utils import is_secure_transport, list_to_scope, scope_to_list def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, - scope=None, state=None, **kwargs): + scope=None, state=None, code_challenge=None, code_challenge_method='plain', **kwargs): """Prepare the authorization grant request URI. The client constructs the request URI by adding the following @@ -45,6 +45,11 @@ 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 paramater. 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 + code_challenge. Defaults to "plain" if not present in the request. :param kwargs: Extra arguments to embed in the grant/authorization URL. An example of an authorization code grant authorization URL: @@ -52,6 +57,7 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, .. code-block:: http GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz + &code_challenge=kjasBS523KdkAILD2k78NdcJSk2k3KHG6&code_challenge_method=S256 &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 Host: server.example.com @@ -73,6 +79,9 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, params.append(('scope', list_to_scope(scope))) if state: params.append(('state', state)) + if code_challenge is not None: + params.append(('code_challenge', code_challenge)) + params.append(('code_challenge_method', code_challenge_method)) for k in kwargs: if kwargs[k]: @@ -81,7 +90,7 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, return add_params_to_uri(uri, params) -def prepare_token_request(grant_type, body='', include_client_id=True, **kwargs): +def prepare_token_request(grant_type, body='', include_client_id=True, code_verifier=None, **kwargs): """Prepare the access token request. The client makes a request to the token endpoint by adding the @@ -116,6 +125,9 @@ def prepare_token_request(grant_type, body='', include_client_id=True, **kwargs) authorization request as described in `Section 4.1.1`_, and their values MUST be identical. * + :param code_verifier: PKCE parameter. A cryptographically random string that is used to correlate the + authorization request to the token request. + :param kwargs: Extra arguments to embed in the request body. Parameters marked with a `*` above are not explicit arguments in the @@ -142,6 +154,10 @@ def prepare_token_request(grant_type, body='', include_client_id=True, **kwargs) if 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: + params.append(('code_verifier', code_verifier)) + # the kwargs iteration below only supports including boolean truth (truthy) # values, but some servers may require an empty string for `client_secret` client_secret = kwargs.pop('client_secret', None) diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py index 817d594..610a708 100644 --- a/oauthlib/oauth2/rfc6749/request_validator.py +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -649,3 +649,28 @@ class RequestValidator: """ raise NotImplementedError('Subclasses must implement this method.') + + def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): + """Indicate if the given origin is allowed to access the token endpoint + via Cross-Origin Resource Sharing (CORS). CORS is used by browser-based + clients, such as Single-Page Applications, to perform the Authorization + Code Grant. + + (Note: If performing Authorization Code Grant via a public client such + as a browser, you should use PKCE as well.) + + If this method returns true, the appropriate CORS headers will be added + to the response. By default this method always returns False, meaning + CORS is disabled. + + :param client_id: Unicode client identifier. + :param redirect_uri: Unicode origin. + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: bool + + Method is used by: + - Authorization Code Grant + + """ + return False diff --git a/oauthlib/oauth2/rfc8628/__init__.py b/oauthlib/oauth2/rfc8628/__init__.py new file mode 100644 index 0000000..531929d --- /dev/null +++ b/oauthlib/oauth2/rfc8628/__init__.py @@ -0,0 +1,10 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 Device Authorization RFC8628. +""" +import logging + +log = logging.getLogger(__name__) diff --git a/oauthlib/oauth2/rfc8628/clients/__init__.py b/oauthlib/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 0000000..130b52e --- /dev/null +++ b/oauthlib/oauth2/rfc8628/clients/__init__.py @@ -0,0 +1,8 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming OAuth 2.0 Device Authorization RFC8628. +""" +from .device import DeviceClient diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py new file mode 100644 index 0000000..df7ff68 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/clients/device.py @@ -0,0 +1,94 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 Device Authorization RFC8628. +""" + +from oauthlib.oauth2 import BackendApplicationClient, Client +from oauthlib.oauth2.rfc6749.errors import InsecureTransportError +from oauthlib.oauth2.rfc6749.parameters import prepare_token_request +from oauthlib.oauth2.rfc6749.utils import is_secure_transport, list_to_scope +from oauthlib.common import add_params_to_uri + + +class DeviceClient(Client): + + """A public client utilizing the device authorization workflow. + + The client can request an access token using a device code and + a public client id associated with the device code as defined + in RFC8628. + + The device authorization grant type can be used to obtain both + access tokens and refresh tokens and is intended to be used in + a scenario where the device being authorized does not have a + user interface that is suitable for performing authentication. + """ + + grant_type = 'urn:ietf:params:oauth:grant-type:device_code' + + def __init__(self, client_id, **kwargs): + super().__init__(client_id, **kwargs) + self.client_secret = kwargs.get('client_secret') + + def prepare_request_uri(self, uri, scope=None, **kwargs): + if not is_secure_transport(uri): + raise InsecureTransportError() + + scope = self.scope if scope is None else scope + params = [(('client_id', self.client_id)), (('grant_type', self.grant_type))] + + if self.client_secret is not None: + params.append(('client_secret', self.client_secret)) + + if scope: + params.append(('scope', list_to_scope(scope))) + + for k in kwargs: + if kwargs[k]: + params.append((str(k), kwargs[k])) + + return add_params_to_uri(uri, params) + + def prepare_request_body(self, device_code, body='', scope=None, + include_client_id=False, **kwargs): + """Add device_code to request body + + The client makes a request to the token endpoint by adding the + device_code as a parameter using the + "application/x-www-form-urlencoded" format to the HTTP request + body. + + :param body: Existing request body (URL encoded string) to embed parameters + into. This may contain extra paramters. Default ''. + :param scope: The scope of the access request as described by + `Section 3.3`_. + + :param include_client_id: `True` to send the `client_id` in the + body of the upstream request. This is required + if the client is not authenticating with the + authorization server as described in + `Section 3.2.1`_. False otherwise (default). + :type include_client_id: Boolean + + :param kwargs: Extra credentials to include in the token request. + + The prepared body will include all provided device_code as well as + the ``grant_type`` parameter set to + ``urn:ietf:params:oauth:grant-type:device_code``:: + + >>> from oauthlib.oauth2 import BackendApplicationClient + >>> client = DeviceClient('your_id', 'your_code') + >>> client.prepare_request_body(scope=['hello', 'world']) + 'grant_type=urn:ietf:params:oauth:grant-type:device_code&scope=hello+world' + + .. _`Section 3.4`: https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + """ + + kwargs['client_id'] = self.client_id + kwargs['include_client_id'] = include_client_id + scope = self.scope if scope is None else scope + return prepare_token_request(self.grant_type, body=body, device_code=device_code, + scope=scope, **kwargs) diff --git a/oauthlib/openid/connect/core/grant_types/__init__.py b/oauthlib/openid/connect/core/grant_types/__init__.py index 887a585..8dad5f6 100644 --- a/oauthlib/openid/connect/core/grant_types/__init__.py +++ b/oauthlib/openid/connect/core/grant_types/__init__.py @@ -10,3 +10,4 @@ from .dispatchers import ( ) from .hybrid import HybridGrant from .implicit import ImplicitGrant +from .refresh_token import RefreshTokenGrant diff --git a/oauthlib/openid/connect/core/grant_types/refresh_token.py b/oauthlib/openid/connect/core/grant_types/refresh_token.py new file mode 100644 index 0000000..43e4499 --- /dev/null +++ b/oauthlib/openid/connect/core/grant_types/refresh_token.py @@ -0,0 +1,34 @@ +""" +oauthlib.openid.connect.core.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +import logging + +from oauthlib.oauth2.rfc6749.grant_types.refresh_token import ( + RefreshTokenGrant as OAuth2RefreshTokenGrant, +) + +from .base import GrantTypeBase + +log = logging.getLogger(__name__) + + +class RefreshTokenGrant(GrantTypeBase): + + def __init__(self, request_validator=None, **kwargs): + self.proxy_target = OAuth2RefreshTokenGrant( + request_validator=request_validator, **kwargs) + self.register_token_modifier(self.add_id_token) + + def add_id_token(self, token, token_handler, request): + """ + Construct an initial version of id_token, and let the + request_validator sign or encrypt it. + + The authorization_code version of this method is used to + retrieve the nonce accordingly to the code storage. + """ + if not self.request_validator.refresh_id_token(request): + return token + + return super().add_id_token(token, token_handler, request) diff --git a/oauthlib/openid/connect/core/request_validator.py b/oauthlib/openid/connect/core/request_validator.py index e8f334b..47c4cd9 100644 --- a/oauthlib/openid/connect/core/request_validator.py +++ b/oauthlib/openid/connect/core/request_validator.py @@ -306,3 +306,15 @@ class RequestValidator(OAuth2RequestValidator): Method is used by: UserInfoEndpoint """ + + def refresh_id_token(self, request): + """Whether the id token should be refreshed. Default, True + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + RefreshTokenGrant + """ + return True diff --git a/requirements.txt b/requirements.txt index c52e6d7..c3c427e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ pyjwt>=2.0.0,<3 blinker==1.4 -cryptography>=3.0.0,<4 +cryptography>=3.0.0 @@ -1,6 +1,3 @@ -[bdist_wheel] -universal = 1 - [metadata] license_file = LICENSE @@ -16,8 +16,8 @@ def fread(fn): return f.read() -rsa_require = ['cryptography>=3.0.0,<4'] -signedtoken_require = ['cryptography>=3.0.0,<4', 'pyjwt>=2.0.0,<3'] +rsa_require = ['cryptography>=3.0.0'] +signedtoken_require = ['cryptography>=3.0.0', 'pyjwt>=2.0.0,<3'] signals_require = ['blinker>=1.4.0'] setup( @@ -54,6 +54,8 @@ setup( '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 :: Only', 'Programming Language :: Python :: Implementation', 'Programming Language :: Python :: Implementation :: CPython', diff --git a/tests/oauth2/rfc6749/clients/test_base.py b/tests/oauth2/rfc6749/clients/test_base.py index c77cfed..70a2283 100644 --- a/tests/oauth2/rfc6749/clients/test_base.py +++ b/tests/oauth2/rfc6749/clients/test_base.py @@ -301,3 +301,55 @@ class ClientTest(TestCase): self.assertEqual(u, url) 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 + code_verifier = client.create_code_verifier(length=length) + self.assertEqual(client.code_verifier, code_verifier) + + def test_create_code_verifier_max_length(self): + client = Client(self.client_id) + length = 128 + code_verifier = client.create_code_verifier(length=length) + self.assertEqual(client.code_verifier, code_verifier) + + def test_create_code_challenge_plain(self): + client = Client(self.client_id) + code_verifier = client.create_code_verifier(length=128) + code_challenge_plain = client.create_code_challenge(code_verifier=code_verifier) + + # if no code_challenge_method specified, code_challenge = code_verifier + self.assertEqual(code_challenge_plain, client.code_verifier) + self.assertEqual(client.code_challenge_method, "plain") + + def test_create_code_challenge_s256(self): + client = Client(self.client_id) + 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) diff --git a/tests/oauth2/rfc6749/clients/test_web_application.py b/tests/oauth2/rfc6749/clients/test_web_application.py index 1f711f4..f6b9449 100644 --- a/tests/oauth2/rfc6749/clients/test_web_application.py +++ b/tests/oauth2/rfc6749/clients/test_web_application.py @@ -24,10 +24,15 @@ class WebApplicationClientTest(TestCase): uri_id = uri + "&response_type=code&client_id=" + client_id uri_redirect = uri_id + "&redirect_uri=http%3A%2F%2Fmy.page.com%2Fcallback" redirect_uri = "http://my.page.com/callback" + code_verifier = "code_verifier" scope = ["/profile"] state = "xyz" + code_challenge = "code_challenge" + code_challenge_method = "S256" uri_scope = uri_id + "&scope=%2Fprofile" uri_state = uri_id + "&state=" + state + uri_code_challenge = uri_id + "&code_challenge=" + code_challenge + "&code_challenge_method=" + code_challenge_method + uri_code_challenge_method = uri_id + "&code_challenge=" + code_challenge + "&code_challenge_method=plain" kwargs = { "some": "providers", "require": "extra arguments" @@ -40,6 +45,7 @@ class WebApplicationClientTest(TestCase): body_code = "not=empty&grant_type=authorization_code&code={}&client_id={}".format(code, client_id) body_redirect = body_code + "&redirect_uri=http%3A%2F%2Fmy.page.com%2Fcallback" + bode_code_verifier = body_code + "&code_verifier=code_verifier" body_kwargs = body_code + "&some=providers&require=extra+arguments" response_uri = "https://client.example.com/cb?code=zzzzaaaa&state=xyz" @@ -80,6 +86,14 @@ class WebApplicationClientTest(TestCase): uri = client.prepare_request_uri(self.uri, state=self.state) self.assertURLEqual(uri, self.uri_state) + # with code_challenge and code_challenge_method + uri = client.prepare_request_uri(self.uri, code_challenge=self.code_challenge, code_challenge_method=self.code_challenge_method) + self.assertURLEqual(uri, self.uri_code_challenge) + + # with no code_challenge_method + uri = client.prepare_request_uri(self.uri, code_challenge=self.code_challenge) + self.assertURLEqual(uri, self.uri_code_challenge_method) + # With extra parameters through kwargs uri = client.prepare_request_uri(self.uri, **self.kwargs) self.assertURLEqual(uri, self.uri_kwargs) @@ -99,6 +113,10 @@ class WebApplicationClientTest(TestCase): body = client.prepare_request_body(body=self.body, redirect_uri=self.redirect_uri) self.assertFormBodyEqual(body, self.body_redirect) + # With code verifier + body = client.prepare_request_body(body=self.body, code_verifier=self.code_verifier) + self.assertFormBodyEqual(body, self.bode_code_verifier) + # With extra parameters body = client.prepare_request_body(body=self.body, **self.kwargs) self.assertFormBodyEqual(body, self.body_kwargs) diff --git a/tests/oauth2/rfc6749/endpoints/test_error_responses.py b/tests/oauth2/rfc6749/endpoints/test_error_responses.py index 3f53c71..f61595e 100644 --- a/tests/oauth2/rfc6749/endpoints/test_error_responses.py +++ b/tests/oauth2/rfc6749/endpoints/test_error_responses.py @@ -178,21 +178,21 @@ class ErrorResponseTest(TestCase): description = 'Duplicate client_id parameter.' # Authorization code - self.assertRaisesRegexp(errors.InvalidRequestFatalError, + self.assertRaisesRegex(errors.InvalidRequestFatalError, description, self.web.validate_authorization_request, uri.format('code')) - self.assertRaisesRegexp(errors.InvalidRequestFatalError, + self.assertRaisesRegex(errors.InvalidRequestFatalError, description, self.web.create_authorization_response, uri.format('code'), scopes=['foo']) # Implicit grant - self.assertRaisesRegexp(errors.InvalidRequestFatalError, + self.assertRaisesRegex(errors.InvalidRequestFatalError, description, self.mobile.validate_authorization_request, uri.format('token')) - self.assertRaisesRegexp(errors.InvalidRequestFatalError, + self.assertRaisesRegex(errors.InvalidRequestFatalError, description, self.mobile.create_authorization_response, uri.format('token'), scopes=['foo']) diff --git a/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py b/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py index 04df6a2..6d3d119 100644 --- a/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py +++ b/tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py @@ -87,7 +87,7 @@ class IntrospectEndpointTest(TestCase): 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', - "WWW-Authenticate": 'Bearer, error="invalid_client"' + "WWW-Authenticate": 'Bearer error="invalid_client"' }) self.assertEqual(loads(b)['error'], 'invalid_client') self.assertEqual(s, 401) @@ -115,7 +115,7 @@ class IntrospectEndpointTest(TestCase): 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', - "WWW-Authenticate": 'Bearer, error="invalid_client"' + "WWW-Authenticate": 'Bearer error="invalid_client"' }) self.assertEqual(loads(b)['error'], 'invalid_client') self.assertEqual(s, 401) diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index 681119a..d93f849 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from oauthlib.oauth2 import MetadataEndpoint, Server, TokenEndpoint +import json from tests.unittest import TestCase @@ -37,6 +38,20 @@ class MetadataEndpointTest(TestCase): self.maxDiff = None self.assertEqual(openid_claims, oauth2_claims) + def test_create_metadata_response(self): + endpoint = TokenEndpoint(None, None, grant_types={"password": None}) + metadata = MetadataEndpoint([endpoint], { + "issuer": 'https://foo.bar', + "token_endpoint": "https://foo.bar/token" + }) + headers, body, status = metadata.create_metadata_response('/', 'GET') + assert headers == { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + } + claims = json.loads(body) + assert claims['issuer'] == 'https://foo.bar' + def test_token_endpoint(self): endpoint = TokenEndpoint(None, None, grant_types={"password": None}) metadata = MetadataEndpoint([endpoint], { diff --git a/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py b/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py index a4182eb..338dbd9 100644 --- a/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py +++ b/tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py @@ -55,7 +55,7 @@ class RevocationEndpointTest(TestCase): 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', - "WWW-Authenticate": 'Bearer, error="invalid_client"' + "WWW-Authenticate": 'Bearer error="invalid_client"' }) self.assertEqual(loads(b)['error'], 'invalid_client') self.assertEqual(s, 401) @@ -83,7 +83,7 @@ class RevocationEndpointTest(TestCase): 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', - "WWW-Authenticate": 'Bearer, error="invalid_client"' + "WWW-Authenticate": 'Bearer error="invalid_client"' }) self.assertEqual(loads(b)['error'], 'invalid_client') self.assertEqual(s, 401) diff --git a/tests/oauth2/rfc6749/grant_types/test_authorization_code.py b/tests/oauth2/rfc6749/grant_types/test_authorization_code.py index 20a2416..77e1a81 100644 --- a/tests/oauth2/rfc6749/grant_types/test_authorization_code.py +++ b/tests/oauth2/rfc6749/grant_types/test_authorization_code.py @@ -28,6 +28,7 @@ class AuthorizationCodeGrantTest(TestCase): self.mock_validator = mock.MagicMock() self.mock_validator.is_pkce_required.return_value = False self.mock_validator.get_code_challenge.return_value = None + self.mock_validator.is_origin_allowed.return_value = False self.mock_validator.authenticate_client.side_effect = self.set_client self.auth = AuthorizationCodeGrant(request_validator=self.mock_validator) @@ -324,3 +325,58 @@ class AuthorizationCodeGrantTest(TestCase): authorization_code.code_challenge_method_s256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM") ) + + def test_code_modifier_called(self): + bearer = BearerToken(self.mock_validator) + code_modifier = mock.MagicMock(wraps=lambda grant, *a: grant) + self.auth.register_code_modifier(code_modifier) + self.auth.create_authorization_response(self.request, bearer) + code_modifier.assert_called_once() + + def test_hybrid_token_save(self): + bearer = BearerToken(self.mock_validator) + self.auth.register_code_modifier( + lambda grant, *a: dict(list(grant.items()) + [('access_token', 1)]) + ) + self.auth.create_authorization_response(self.request, bearer) + self.mock_validator.save_token.assert_called_once() + + # CORS + + def test_create_cors_headers(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = True + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertEqual( + headers['Access-Control-Allow-Origin'], 'https://foo.bar' + ) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) + + def test_create_cors_headers_no_origin(self): + bearer = BearerToken(self.mock_validator) + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_insecure_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'http://foo.bar' + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_not_called() + + def test_create_cors_headers_invalid_origin(self): + bearer = BearerToken(self.mock_validator) + self.request.headers['origin'] = 'https://foo.bar' + self.mock_validator.is_origin_allowed.return_value = False + + headers = self.auth.create_token_response(self.request, bearer)[0] + self.assertNotIn('Access-Control-Allow-Origin', headers) + self.mock_validator.is_origin_allowed.assert_called_once_with( + 'abcdef', 'https://foo.bar', self.request + ) diff --git a/tests/oauth2/rfc6749/test_parameters.py b/tests/oauth2/rfc6749/test_parameters.py index f9245ec..cd8c9e9 100644 --- a/tests/oauth2/rfc6749/test_parameters.py +++ b/tests/oauth2/rfc6749/test_parameters.py @@ -21,12 +21,15 @@ class ParameterTests(TestCase): list_scope = ['list', 'of', 'scopes'] auth_grant = {'response_type': 'code'} + auth_grant_pkce = {'response_type': 'code', 'code_challenge': "code_challenge", + 'code_challenge_method': 'code_challenge_method'} auth_grant_list_scope = {} auth_implicit = {'response_type': 'token', 'extra': 'extra'} auth_implicit_list_scope = {} def setUp(self): self.auth_grant.update(self.auth_base) + self.auth_grant_pkce.update(self.auth_base) self.auth_implicit.update(self.auth_base) self.auth_grant_list_scope.update(self.auth_grant) self.auth_grant_list_scope['scope'] = self.list_scope @@ -37,7 +40,14 @@ class ParameterTests(TestCase): '&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2F' 'client.example.com%2Fcb&scope={1}&state={2}{3}') + auth_base_uri_pkce = ('https://server.example.com/authorize?response_type={0}' + '&client_id=s6BhdRkqt3&redirect_uri=https%3A%2F%2F' + 'client.example.com%2Fcb&scope={1}&state={2}{3}&code_challenge={4}' + '&code_challenge_method={5}') + auth_grant_uri = auth_base_uri.format('code', 'photos', state, '') + auth_grant_uri_pkce = auth_base_uri_pkce.format('code', 'photos', state, '', 'code_challenge', + 'code_challenge_method') auth_grant_uri_list_scope = auth_base_uri.format('code', 'list+of+scopes', state, '') auth_implicit_uri = auth_base_uri.format('token', 'photos', state, '&extra=extra') auth_implicit_uri_list_scope = auth_base_uri.format('token', 'list+of+scopes', state, '&extra=extra') @@ -47,11 +57,21 @@ class ParameterTests(TestCase): 'code': 'SplxlOBeZQQYbYS6WxSbIA', 'redirect_uri': 'https://client.example.com/cb' } + grant_body_pkce = { + 'grant_type': 'authorization_code', + 'code': 'SplxlOBeZQQYbYS6WxSbIA', + 'redirect_uri': 'https://client.example.com/cb', + 'code_verifier': 'code_verifier' + } grant_body_scope = {'scope': 'photos'} grant_body_list_scope = {'scope': list_scope} auth_grant_body = ('grant_type=authorization_code&' 'code=SplxlOBeZQQYbYS6WxSbIA&' 'redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb') + auth_grant_body_pkce = ('grant_type=authorization_code&' + 'code=SplxlOBeZQQYbYS6WxSbIA&' + 'redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb' + '&code_verifier=code_verifier') auth_grant_body_scope = auth_grant_body + '&scope=photos' auth_grant_body_list_scope = auth_grant_body + '&scope=list+of+scopes' @@ -179,12 +199,14 @@ class ParameterTests(TestCase): self.assertURLEqual(prepare_grant_uri(**self.auth_grant_list_scope), self.auth_grant_uri_list_scope) self.assertURLEqual(prepare_grant_uri(**self.auth_implicit), self.auth_implicit_uri) self.assertURLEqual(prepare_grant_uri(**self.auth_implicit_list_scope), self.auth_implicit_uri_list_scope) + self.assertURLEqual(prepare_grant_uri(**self.auth_grant_pkce), self.auth_grant_uri_pkce) def test_prepare_token_request(self): """Verify correct access token request body construction.""" self.assertFormBodyEqual(prepare_token_request(**self.grant_body), self.auth_grant_body) self.assertFormBodyEqual(prepare_token_request(**self.pwd_body), self.password_body) self.assertFormBodyEqual(prepare_token_request(**self.cred_grant), self.cred_body) + self.assertFormBodyEqual(prepare_token_request(**self.grant_body_pkce), self.auth_grant_body_pkce) def test_grant_response(self): """Verify correct parameter parsing and validation for auth code responses.""" diff --git a/tests/oauth2/rfc6749/test_request_validator.py b/tests/oauth2/rfc6749/test_request_validator.py index 9688b5a..7a8d06b 100644 --- a/tests/oauth2/rfc6749/test_request_validator.py +++ b/tests/oauth2/rfc6749/test_request_validator.py @@ -46,3 +46,6 @@ class RequestValidatorTest(TestCase): self.assertRaises(NotImplementedError, v.validate_user, 'username', 'password', 'client', 'request') self.assertTrue(v.client_authentication_required('r')) + self.assertFalse( + v.is_origin_allowed('client_id', 'https://foo.bar', 'r') + ) diff --git a/tests/oauth2/rfc8628/__init__.py b/tests/oauth2/rfc8628/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/oauth2/rfc8628/__init__.py diff --git a/tests/oauth2/rfc8628/clients/__init__.py b/tests/oauth2/rfc8628/clients/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/oauth2/rfc8628/clients/__init__.py diff --git a/tests/oauth2/rfc8628/clients/test_device.py b/tests/oauth2/rfc8628/clients/test_device.py new file mode 100644 index 0000000..725dea2 --- /dev/null +++ b/tests/oauth2/rfc8628/clients/test_device.py @@ -0,0 +1,63 @@ +import os +from unittest.mock import patch + +from oauthlib import signals +from oauthlib.oauth2 import DeviceClient + +from tests.unittest import TestCase + + +class DeviceClientTest(TestCase): + + client_id = "someclientid" + kwargs = { + "some": "providers", + "require": "extra arguments" + } + + client_secret = "asecret" + + device_code = "somedevicecode" + + scope = ["profile", "email"] + + body = "not=empty" + + body_up = "not=empty&grant_type=urn:ietf:params:oauth:grant-type:device_code" + body_code = body_up + "&device_code=somedevicecode" + body_kwargs = body_code + "&some=providers&require=extra+arguments" + + uri = "https://example.com/path?query=world" + uri_id = uri + "&client_id=" + client_id + uri_grant = uri_id + "&grant_type=urn:ietf:params:oauth:grant-type:device_code" + uri_secret = uri_grant + "&client_secret=asecret" + uri_scope = uri_secret + "&scope=profile+email" + + def test_request_body(self): + client = DeviceClient(self.client_id) + + # Basic, no extra arguments + body = client.prepare_request_body(self.device_code, body=self.body) + self.assertFormBodyEqual(body, self.body_code) + + rclient = DeviceClient(self.client_id) + body = rclient.prepare_request_body(self.device_code, body=self.body) + self.assertFormBodyEqual(body, self.body_code) + + # With extra parameters + body = client.prepare_request_body( + self.device_code, body=self.body, **self.kwargs) + self.assertFormBodyEqual(body, self.body_kwargs) + + def test_request_uri(self): + client = DeviceClient(self.client_id) + + uri = client.prepare_request_uri(self.uri) + self.assertURLEqual(uri, self.uri_grant) + + client = DeviceClient(self.client_id, client_secret=self.client_secret) + uri = client.prepare_request_uri(self.uri) + self.assertURLEqual(uri, self.uri_secret) + + uri = client.prepare_request_uri(self.uri, scope=self.scope) + self.assertURLEqual(uri, self.uri_scope) diff --git a/tests/openid/connect/core/grant_types/test_refresh_token.py b/tests/openid/connect/core/grant_types/test_refresh_token.py new file mode 100644 index 0000000..8126e1b --- /dev/null +++ b/tests/openid/connect/core/grant_types/test_refresh_token.py @@ -0,0 +1,105 @@ +import json +from unittest import mock + +from oauthlib.common import Request +from oauthlib.oauth2.rfc6749.tokens import BearerToken +from oauthlib.openid.connect.core.grant_types import RefreshTokenGrant + +from tests.oauth2.rfc6749.grant_types.test_refresh_token import ( + RefreshTokenGrantTest, +) +from tests.unittest import TestCase + + +def get_id_token_mock(token, token_handler, request): + return "MOCKED_TOKEN" + + +class OpenIDRefreshTokenInterferenceTest(RefreshTokenGrantTest): + """Test that OpenID don't interfere with normal OAuth 2 flows.""" + + def setUp(self): + super().setUp() + self.auth = RefreshTokenGrant(request_validator=self.mock_validator) + + +class OpenIDRefreshTokenTest(TestCase): + + def setUp(self): + self.request = Request('http://a.b/path') + self.request.grant_type = 'refresh_token' + self.request.refresh_token = 'lsdkfhj230' + self.request.scope = ('hello', 'openid') + self.mock_validator = mock.MagicMock() + + self.mock_validator = mock.MagicMock() + self.mock_validator.authenticate_client.side_effect = self.set_client + self.mock_validator.get_id_token.side_effect = get_id_token_mock + self.auth = RefreshTokenGrant(request_validator=self.mock_validator) + + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = 'mocked' + return True + + def test_refresh_id_token(self): + self.mock_validator.get_original_scopes.return_value = [ + 'hello', 'openid' + ] + bearer = BearerToken(self.mock_validator) + + headers, body, status_code = self.auth.create_token_response( + self.request, bearer + ) + + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('id_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'hello openid') + self.mock_validator.refresh_id_token.assert_called_once_with( + self.request + ) + + def test_refresh_id_token_false(self): + self.mock_validator.refresh_id_token.return_value = False + self.mock_validator.get_original_scopes.return_value = [ + 'hello', 'openid' + ] + bearer = BearerToken(self.mock_validator) + + headers, body, status_code = self.auth.create_token_response( + self.request, bearer + ) + + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertEqual(token['scope'], 'hello openid') + self.assertNotIn('id_token', token) + self.mock_validator.refresh_id_token.assert_called_once_with( + self.request + ) + + def test_refresh_token_without_openid_scope(self): + self.request.scope = "hello" + bearer = BearerToken(self.mock_validator) + + headers, body, status_code = self.auth.create_token_response( + self.request, bearer + ) + + token = json.loads(body) + self.assertEqual(self.mock_validator.save_token.call_count, 1) + self.assertIn('access_token', token) + self.assertIn('refresh_token', token) + self.assertIn('token_type', token) + self.assertIn('expires_in', token) + self.assertNotIn('id_token', token) + self.assertEqual(token['scope'], 'hello') @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37,py38,pypy3,docs,readme,bandit,isort +envlist = py36,py37,py38,py39,py310,py311,pypy3,docs,readme,bandit,isort [testenv] deps= |