summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coveragerc1
-rw-r--r--.travis.yml23
-rw-r--r--CHANGELOG.rst5
-rw-r--r--docs/installation.rst2
-rw-r--r--docs/oauth2/clients/deviceclient.rst5
-rw-r--r--docs/oauth2/oidc/refresh_token.rst6
-rw-r--r--oauthlib/oauth2/__init__.py1
-rw-r--r--oauthlib/oauth2/rfc6749/clients/base.py109
-rw-r--r--oauthlib/oauth2/rfc6749/clients/web_application.py25
-rw-r--r--oauthlib/oauth2/rfc6749/endpoints/metadata.py3
-rw-r--r--oauthlib/oauth2/rfc6749/errors.py7
-rw-r--r--oauthlib/oauth2/rfc6749/grant_types/authorization_code.py21
-rw-r--r--oauthlib/oauth2/rfc6749/grant_types/refresh_token.py2
-rw-r--r--oauthlib/oauth2/rfc6749/parameters.py20
-rw-r--r--oauthlib/oauth2/rfc6749/request_validator.py25
-rw-r--r--oauthlib/oauth2/rfc8628/__init__.py10
-rw-r--r--oauthlib/oauth2/rfc8628/clients/__init__.py8
-rw-r--r--oauthlib/oauth2/rfc8628/clients/device.py94
-rw-r--r--oauthlib/openid/connect/core/grant_types/__init__.py1
-rw-r--r--oauthlib/openid/connect/core/grant_types/refresh_token.py34
-rw-r--r--oauthlib/openid/connect/core/request_validator.py12
-rw-r--r--requirements.txt2
-rw-r--r--setup.cfg3
-rwxr-xr-xsetup.py6
-rw-r--r--tests/oauth2/rfc6749/clients/test_base.py52
-rw-r--r--tests/oauth2/rfc6749/clients/test_web_application.py18
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_error_responses.py8
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_introspect_endpoint.py4
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_metadata.py15
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_revocation_endpoint.py4
-rw-r--r--tests/oauth2/rfc6749/grant_types/test_authorization_code.py56
-rw-r--r--tests/oauth2/rfc6749/test_parameters.py22
-rw-r--r--tests/oauth2/rfc6749/test_request_validator.py3
-rw-r--r--tests/oauth2/rfc8628/__init__.py0
-rw-r--r--tests/oauth2/rfc8628/clients/__init__.py0
-rw-r--r--tests/oauth2/rfc8628/clients/test_device.py63
-rw-r--r--tests/openid/connect/core/grant_types/test_refresh_token.py105
-rw-r--r--tox.ini2
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
diff --git a/setup.cfg b/setup.cfg
index 3128e18..ca59291 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,3 @@
-[bdist_wheel]
-universal = 1
-
[metadata]
license_file = LICENSE
diff --git a/setup.py b/setup.py
index 124d534..0192458 100755
--- a/setup.py
+++ b/setup.py
@@ -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')
diff --git a/tox.ini b/tox.ini
index cec4ba5..8e77f3b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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=