summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonathan Huot <JonathanHuot@users.noreply.github.com>2018-12-16 18:58:18 +0100
committerGitHub <noreply@github.com>2018-12-16 18:58:18 +0100
commit2ae71acab9c78598aef10ba584a7284ffa07b48f (patch)
tree2a2ebf514137c92e124d4c3718de09338d13d848
parent6f3aa6c3a89ac9a61831bd67f061725f5b5661fb (diff)
parent8de1e52fabdd9c3738a012bb88e2234d1bd06255 (diff)
downloadoauthlib-bandit.tar.gz
Merge branch 'master' into banditbandit
-rw-r--r--docs/feature_matrix.rst9
-rw-r--r--docs/oauth2/server.rst11
-rw-r--r--oauthlib/common.py3
-rw-r--r--oauthlib/oauth2/rfc6749/endpoints/metadata.py31
-rw-r--r--oauthlib/oauth2/rfc6749/errors.py32
-rw-r--r--oauthlib/oauth2/rfc6749/grant_types/authorization_code.py110
-rw-r--r--oauthlib/oauth2/rfc6749/request_validator.py108
-rw-r--r--oauthlib/openid/connect/core/endpoints/pre_configured.py6
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_client_authentication.py2
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py1
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_error_responses.py1
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_metadata.py88
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py1
-rw-r--r--tests/oauth2/rfc6749/endpoints/test_scope_handling.py1
-rw-r--r--tests/oauth2/rfc6749/grant_types/test_authorization_code.py124
-rw-r--r--tests/oauth2/rfc6749/test_server.py3
-rw-r--r--tests/openid/connect/core/endpoints/test_claims_handling.py1
-rw-r--r--tests/openid/connect/core/grant_types/test_authorization_code.py1
-rw-r--r--tests/openid/connect/core/grant_types/test_implicit.py2
-rw-r--r--tests/openid/connect/core/test_server.py2
20 files changed, 517 insertions, 20 deletions
diff --git a/docs/feature_matrix.rst b/docs/feature_matrix.rst
index 45010d1..df8cb0e 100644
--- a/docs/feature_matrix.rst
+++ b/docs/feature_matrix.rst
@@ -18,14 +18,16 @@ OAuth 2.0 client and provider support for:
- `RFC7009`_: Token Revocation
- `RFC Draft MAC tokens`_
- OAuth2.0 Provider: `OpenID Connect Core`_
+- OAuth2.0 Provider: `RFC7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE)
- OAuth2.0 Provider: `RFC7662`_: Token Introspection
- OAuth2.0 Provider: `RFC8414`_: Authorization Server Metadata
Features to be implemented (any help/PR are welcomed):
-- OAuth2.0 Client: `OpenID Connect Core`_
-- OAuth2.0 Client: `RFC7662`_: Token Introspection
-- OAuth2.0 Client: `RFC8414`_: Authorization Server Metadata
+- OAuth2.0 **Client**: `OpenID Connect Core`_
+- OAuth2.0 **Client**: `RFC7636`_: Proof Key for Code Exchange by OAuth Public Clients (PKCE)
+- OAuth2.0 **Client**: `RFC7662`_: Token Introspection
+- OAuth2.0 **Client**: `RFC8414`_: Authorization Server Metadata
- SAML2
- Bearer JWT as Client Authentication
- Dynamic client registration
@@ -51,5 +53,6 @@ RSA you are limited to the platforms supported by `cryptography`_.
.. _`RFC Draft MAC tokens`: https://tools.ietf.org/id/draft-ietf-oauth-v2-http-mac-02.html
.. _`RFC7009`: https://tools.ietf.org/html/rfc7009
.. _`RFC7662`: https://tools.ietf.org/html/rfc7662
+.. _`RFC7636`: https://tools.ietf.org/html/rfc7636
.. _`OpenID Connect Core`: https://openid.net/specs/openid-connect-core-1_0.html
.. _`RFC8414`: https://tools.ietf.org/html/rfc8414
diff --git a/docs/oauth2/server.rst b/docs/oauth2/server.rst
index 35a58aa..6c065c5 100644
--- a/docs/oauth2/server.rst
+++ b/docs/oauth2/server.rst
@@ -246,6 +246,17 @@ the token.
expires_at = django.db.models.DateTimeField()
+**PKCE Challenge (optional)**
+
+ If you want to support PKCE, you have to associate a `code_challenge`
+ and a `code_challenge_method` to the actual Authorization Code.
+
+ .. code-block:: python
+
+ challenge = django.db.models.CharField(max_length=128)
+ challenge_method = django.db.models.CharField(max_length=6)
+
+
2. Implement a validator
------------------------
diff --git a/oauthlib/common.py b/oauthlib/common.py
index bd6ec56..970d7a5 100644
--- a/oauthlib/common.py
+++ b/oauthlib/common.py
@@ -397,6 +397,9 @@ class Request(object):
"client_id": None,
"client_secret": None,
"code": None,
+ "code_challenge": None,
+ "code_challenge_method": None,
+ "code_verifier": None,
"extra_credentials": None,
"grant_type": None,
"redirect_uri": None,
diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py
index 6d77b9f..60c846b 100644
--- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py
+++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py
@@ -19,6 +19,7 @@ from .authorization import AuthorizationEndpoint
from .introspect import IntrospectEndpoint
from .token import TokenEndpoint
from .revocation import RevocationEndpoint
+from .. import grant_types
log = logging.getLogger(__name__)
@@ -89,21 +90,39 @@ class MetadataEndpoint(BaseEndpoint):
raise ValueError("array {}: {} must contains only string (not {})".format(key, array[key], elem))
def validate_metadata_token(self, claims, endpoint):
- claims.setdefault("grant_types_supported", list(endpoint._grant_types.keys()))
+ """
+ If the token endpoint is used in the grant type, the value of this
+ parameter MUST be the same as the value of the "grant_type"
+ parameter passed to the token endpoint defined in the grant type
+ definition.
+ """
+ self._grant_types.extend(endpoint._grant_types.keys())
claims.setdefault("token_endpoint_auth_methods_supported", ["client_secret_post", "client_secret_basic"])
- self.validate_metadata(claims, "grant_types_supported", is_list=True)
self.validate_metadata(claims, "token_endpoint_auth_methods_supported", is_list=True)
self.validate_metadata(claims, "token_endpoint_auth_signing_alg_values_supported", is_list=True)
self.validate_metadata(claims, "token_endpoint", is_required=True, is_url=True)
def validate_metadata_authorization(self, claims, endpoint):
- claims.setdefault("response_types_supported", list(self._response_types.keys()))
+ claims.setdefault("response_types_supported",
+ list(filter(lambda x: x != "none", endpoint._response_types.keys())))
claims.setdefault("response_modes_supported", ["query", "fragment"])
+ # The OAuth2.0 Implicit flow is defined as a "grant type" but it is not
+ # using the "token" endpoint, as such, we have to add it explicitly to
+ # the list of "grant_types_supported" when enabled.
+ if "token" in claims["response_types_supported"]:
+ self._grant_types.append("implicit")
+
self.validate_metadata(claims, "response_types_supported", is_required=True, is_list=True)
self.validate_metadata(claims, "response_modes_supported", is_list=True)
if "code" in claims["response_types_supported"]:
+ code_grant = endpoint._response_types["code"]
+ if not isinstance(code_grant, grant_types.AuthorizationCodeGrant) and hasattr(code_grant, "default_grant"):
+ code_grant = code_grant.default_grant
+
+ claims.setdefault("code_challenge_methods_supported",
+ list(code_grant._code_challenge_methods.keys()))
self.validate_metadata(claims, "code_challenge_methods_supported", is_list=True)
self.validate_metadata(claims, "authorization_endpoint", is_required=True, is_url=True)
@@ -181,6 +200,7 @@ class MetadataEndpoint(BaseEndpoint):
self.validate_metadata(claims, "op_policy_uri", is_url=True)
self.validate_metadata(claims, "op_tos_uri", is_url=True)
+ self._grant_types = []
for endpoint in self.endpoints:
if isinstance(endpoint, TokenEndpoint):
self.validate_metadata_token(claims, endpoint)
@@ -190,4 +210,9 @@ class MetadataEndpoint(BaseEndpoint):
self.validate_metadata_revocation(claims, endpoint)
if isinstance(endpoint, IntrospectEndpoint):
self.validate_metadata_introspection(claims, endpoint)
+
+ # "grant_types_supported" is a combination of all OAuth2 grant types
+ # allowed in the current provider implementation.
+ claims.setdefault("grant_types_supported", self._grant_types)
+ self.validate_metadata(claims, "grant_types_supported", is_list=True)
return claims
diff --git a/oauthlib/oauth2/rfc6749/errors.py b/oauthlib/oauth2/rfc6749/errors.py
index ec2b0d1..d2a1402 100644
--- a/oauthlib/oauth2/rfc6749/errors.py
+++ b/oauthlib/oauth2/rfc6749/errors.py
@@ -201,6 +201,26 @@ class MissingResponseTypeError(InvalidRequestError):
description = 'Missing response_type parameter.'
+class MissingCodeChallengeError(InvalidRequestError):
+ """
+ If the server requires Proof Key for Code Exchange (PKCE) by OAuth
+ public clients and the client does not send the "code_challenge" in
+ the request, the authorization endpoint MUST return the authorization
+ error response with the "error" value set to "invalid_request". The
+ "error_description" or the response of "error_uri" SHOULD explain the
+ nature of error, e.g., code challenge required.
+ """
+ description = 'Code challenge required.'
+
+
+class MissingCodeVerifierError(InvalidRequestError):
+ """
+ The request to the token endpoint, when PKCE is enabled, has
+ the parameter `code_verifier` REQUIRED.
+ """
+ description = 'Code verifier required.'
+
+
class AccessDeniedError(OAuth2Error):
"""
The resource owner or authorization server denied the request.
@@ -216,6 +236,18 @@ class UnsupportedResponseTypeError(OAuth2Error):
error = 'unsupported_response_type'
+class UnsupportedCodeChallengeMethodError(InvalidRequestError):
+ """
+ If the server supporting PKCE does not support the requested
+ transformation, the authorization endpoint MUST return the
+ authorization error response with "error" value set to
+ "invalid_request". The "error_description" or the response of
+ "error_uri" SHOULD explain the nature of error, e.g., transform
+ algorithm not supported.
+ """
+ description = 'Transform algorithm not supported.'
+
+
class InvalidScopeError(OAuth2Error):
"""
The requested scope is invalid, unknown, or malformed, or
diff --git a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py
index 850d70a..d56330a 100644
--- a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py
+++ b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py
@@ -5,6 +5,8 @@ oauthlib.oauth2.rfc6749.grant_types
"""
from __future__ import absolute_import, unicode_literals
+import base64
+import hashlib
import json
import logging
@@ -17,6 +19,52 @@ from .base import GrantTypeBase
log = logging.getLogger(__name__)
+def code_challenge_method_s256(verifier, challenge):
+ """
+ If the "code_challenge_method" from `Section 4.3`_ was "S256", the
+ received "code_verifier" is hashed by SHA-256, base64url-encoded, and
+ then compared to the "code_challenge", i.e.:
+
+ BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
+
+ How to implement a base64url-encoding
+ function without padding, based upon the standard base64-encoding
+ function that uses padding.
+
+ To be concrete, example C# code implementing these functions is shown
+ below. Similar code could be used in other languages.
+
+ static string base64urlencode(byte [] arg)
+ {
+ string s = Convert.ToBase64String(arg); // Regular base64 encoder
+ s = s.Split('=')[0]; // Remove any trailing '='s
+ s = s.Replace('+', '-'); // 62nd char of encoding
+ s = s.Replace('/', '_'); // 63rd char of encoding
+ return s;
+ }
+
+ In python urlsafe_b64encode is already replacing '+' and '/', but preserve
+ the trailing '='. So we have to remove it.
+
+ .. _`Section 4.3`: https://tools.ietf.org/html/rfc7636#section-4.3
+ """
+ return base64.urlsafe_b64encode(
+ hashlib.sha256(verifier.encode()).digest()
+ ).decode().rstrip('=') == challenge
+
+
+def code_challenge_method_plain(verifier, challenge):
+ """
+ If the "code_challenge_method" from `Section 4.3`_ was "plain", they are
+ compared directly, i.e.:
+
+ code_verifier == code_challenge.
+
+ .. _`Section 4.3`: https://tools.ietf.org/html/rfc7636#section-4.3
+ """
+ return verifier == challenge
+
+
class AuthorizationCodeGrant(GrantTypeBase):
"""`Authorization Code Grant`_
@@ -91,12 +139,28 @@ class AuthorizationCodeGrant(GrantTypeBase):
step (C). If valid, the authorization server responds back with
an access token and, optionally, a refresh token.
+ OAuth 2.0 public clients utilizing the Authorization Code Grant are
+ susceptible to the authorization code interception attack.
+
+ A technique to mitigate against the threat through the use of Proof Key for Code
+ Exchange (PKCE, pronounced "pixy") is implemented in the current oauthlib
+ implementation.
+
.. _`Authorization Code Grant`: https://tools.ietf.org/html/rfc6749#section-4.1
+ .. _`PKCE`: https://tools.ietf.org/html/rfc7636
"""
default_response_mode = 'query'
response_types = ['code']
+ # This dict below is private because as RFC mention it:
+ # "S256" is Mandatory To Implement (MTI) on the server.
+ #
+ _code_challenge_methods = {
+ 'plain': code_challenge_method_plain,
+ 'S256': code_challenge_method_s256
+ }
+
def create_authorization_code(self, request):
"""
Generates an authorization grant represented as a dictionary.
@@ -351,6 +415,20 @@ class AuthorizationCodeGrant(GrantTypeBase):
request.client_id, request.response_type)
raise errors.UnauthorizedClientError(request=request)
+ # OPTIONAL. Validate PKCE request or reply with "error"/"invalid_request"
+ # https://tools.ietf.org/html/rfc6749#section-4.4.1
+ if self.request_validator.is_pkce_required(request.client_id, request) is True:
+ if request.code_challenge is None:
+ raise errors.MissingCodeChallengeError(request=request)
+
+ if request.code_challenge is not None:
+ # OPTIONAL, defaults to "plain" if not present in the request.
+ if request.code_challenge_method is None:
+ request.code_challenge_method = "plain"
+
+ if request.code_challenge_method not in self._code_challenge_methods:
+ raise errors.UnsupportedCodeChallengeMethodError(request=request)
+
# OPTIONAL. The scope of the access request as described by Section 3.3
# https://tools.ietf.org/html/rfc6749#section-3.3
self.validate_scopes(request)
@@ -423,6 +501,33 @@ class AuthorizationCodeGrant(GrantTypeBase):
request.client_id, request.client, request.scopes)
raise errors.InvalidGrantError(request=request)
+ # OPTIONAL. Validate PKCE code_verifier
+ challenge = self.request_validator.get_code_challenge(request.code, request)
+
+ if challenge is not None:
+ if request.code_verifier is None:
+ raise errors.MissingCodeVerifierError(request=request)
+
+ challenge_method = self.request_validator.get_code_challenge_method(request.code, request)
+ if challenge_method is None:
+ raise errors.InvalidGrantError(request=request, description="Challenge method not found")
+
+ if challenge_method not in self._code_challenge_methods:
+ raise errors.ServerError(
+ description="code_challenge_method {} is not supported.".format(challenge_method),
+ request=request
+ )
+
+ if not self.validate_code_challenge(challenge,
+ challenge_method,
+ request.code_verifier):
+ log.debug('request provided a invalid code_verifier.')
+ raise errors.InvalidGrantError(request=request)
+ elif self.request_validator.is_pkce_required(request.client_id, request) is True:
+ if request.code_verifier is None:
+ raise errors.MissingCodeVerifierError(request=request)
+ raise errors.InvalidGrantError(request=request, description="Challenge not found")
+
for attr in ('user', 'scopes'):
if getattr(request, attr, None) is None:
log.debug('request.%s was not set on code validation.', attr)
@@ -450,3 +555,8 @@ class AuthorizationCodeGrant(GrantTypeBase):
for validator in self.custom_validators.post_token:
validator(request)
+
+ def validate_code_challenge(self, challenge, challenge_method, verifier):
+ 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)
diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py
index 2cf1b82..193a9e1 100644
--- a/oauthlib/oauth2/rfc6749/request_validator.py
+++ b/oauthlib/oauth2/rfc6749/request_validator.py
@@ -262,25 +262,29 @@ class RequestValidator(object):
"""Persist the authorization_code.
The code should at minimum be stored with:
- - the client_id (client_id)
- - the redirect URI used (request.redirect_uri)
- - a resource owner / user (request.user)
- - the authorized scopes (request.scopes)
- - the client state, if given (code.get('state'))
+ - the client_id (``client_id``)
+ - the redirect URI used (``request.redirect_uri``)
+ - a resource owner / user (``request.user``)
+ - the authorized scopes (``request.scopes``)
+ - the client state, if given (``code.get('state')``)
+
+ To support PKCE, you MUST associate the code with:
+ - Code Challenge (``request.code_challenge``) and
+ - Code Challenge Method (``request.code_challenge_method``)
- The 'code' argument is actually a dictionary, containing at least a
- 'code' key with the actual authorization code:
+ The ``code`` argument is actually a dictionary, containing at least a
+ ``code`` key with the actual authorization code:
- {'code': 'sdf345jsdf0934f'}
+ ``{'code': 'sdf345jsdf0934f'}``
- It may also have a 'state' key containing a nonce for the client, if it
+ It may also have a ``state`` key containing a nonce for the client, if it
chose to send one. That value should be saved and used in
- 'validate_code'.
+ ``.validate_code``.
- It may also have a 'claims' parameter which, when present, will be a dict
+ It may also have a ``claims`` parameter which, when present, will be a dict
deserialized from JSON as described at
http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
- This value should be saved in this method and used again in 'validate_code'.
+ This value should be saved in this method and used again in ``.validate_code``.
:param client_id: Unicode client identifier.
:param code: A dict of the authorization code grant and, optionally, state.
@@ -564,6 +568,11 @@ class RequestValidator(object):
The request.claims property, if it was given, should assigned a dict.
+ If PKCE is enabled (see 'is_pkce_required' and 'save_authorization_code')
+ you MUST set the following based on the information stored:
+ - request.code_challenge
+ - request.code_challenge_method
+
:param client_id: Unicode client identifier.
:param code: Unicode authorization code.
:param client: Client object set by you, see ``.authenticate_client``.
@@ -742,3 +751,78 @@ class RequestValidator(object):
- OpenIDConnectHybrid
"""
raise NotImplementedError('Subclasses must implement this method.')
+
+ def is_pkce_required(self, client_id, request):
+ """Determine if current request requires PKCE. Default, False.
+ This is called for both "authorization" and "token" requests.
+
+ Override this method by ``return True`` to enable PKCE for everyone.
+ You might want to enable it only for public clients.
+ Note that PKCE can also be used in addition of a client authentication.
+
+ OAuth 2.0 public clients utilizing the Authorization Code Grant are
+ susceptible to the authorization code interception attack. This
+ specification describes the attack as well as a technique to mitigate
+ against the threat through the use of Proof Key for Code Exchange
+ (PKCE, pronounced "pixy"). See `RFC7636`_.
+
+ :param client_id: Client identifier.
+ :param request: OAuthlib request.
+ :type request: oauthlib.common.Request
+ :rtype: True or False
+
+ Method is used by:
+ - Authorization Code Grant
+
+ .. _`RFC7636`: https://tools.ietf.org/html/rfc7636
+ """
+ return False
+
+ def get_code_challenge(self, code, request):
+ """Is called for every "token" requests.
+
+ When the server issues the authorization code in the authorization
+ response, it MUST associate the ``code_challenge`` and
+ ``code_challenge_method`` values with the authorization code so it can
+ be verified later.
+
+ Typically, the ``code_challenge`` and ``code_challenge_method`` values
+ are stored in encrypted form in the ``code`` itself but could
+ alternatively be stored on the server associated with the code. The
+ server MUST NOT include the ``code_challenge`` value in client requests
+ in a form that other entities can extract.
+
+ Return the ``code_challenge`` associated to the code.
+ If ``None`` is returned, code is considered to not be associated to any
+ challenges.
+
+ :param code: Authorization code.
+ :param request: OAuthlib request.
+ :type request: oauthlib.common.Request
+ :rtype: code_challenge string
+
+ Method is used by:
+ - Authorization Code Grant - when PKCE is active
+
+ """
+ return None
+
+ def get_code_challenge_method(self, code, request):
+ """Is called during the "token" request processing, when a
+ ``code_verifier`` and a ``code_challenge`` has been provided.
+
+ See ``.get_code_challenge``.
+
+ Must return ``plain`` or ``S256``. You can return a custom value if you have
+ implemented your own ``AuthorizationCodeGrant`` class.
+
+ :param code: Authorization code.
+ :param request: OAuthlib request.
+ :type request: oauthlib.common.Request
+ :rtype: code_challenge_method string
+
+ Method is used by:
+ - Authorization Code Grant - when PKCE is active
+
+ """
+ raise NotImplementedError('Subclasses must implement this method.')
diff --git a/oauthlib/openid/connect/core/endpoints/pre_configured.py b/oauthlib/openid/connect/core/endpoints/pre_configured.py
index 9cf30db..6367847 100644
--- a/oauthlib/openid/connect/core/endpoints/pre_configured.py
+++ b/oauthlib/openid/connect/core/endpoints/pre_configured.py
@@ -10,6 +10,7 @@ from __future__ import absolute_import, unicode_literals
from oauthlib.oauth2.rfc6749.endpoints import (
AuthorizationEndpoint,
+ IntrospectEndpoint,
ResourceEndpoint,
RevocationEndpoint,
TokenEndpoint
@@ -35,8 +36,8 @@ from ..grant_types.dispatchers import (
from ..tokens import JWTToken
-class Server(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint,
- RevocationEndpoint):
+class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint,
+ ResourceEndpoint, RevocationEndpoint):
"""An all-in-one endpoint featuring all four major grant types."""
@@ -103,3 +104,4 @@ class Server(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint,
ResourceEndpoint.__init__(self, default_token='Bearer',
token_types={'Bearer': bearer, 'JWT': jwt})
RevocationEndpoint.__init__(self, request_validator)
+ IntrospectEndpoint.__init__(self, request_validator)
diff --git a/tests/oauth2/rfc6749/endpoints/test_client_authentication.py b/tests/oauth2/rfc6749/endpoints/test_client_authentication.py
index e9a0673..48c5f5a 100644
--- a/tests/oauth2/rfc6749/endpoints/test_client_authentication.py
+++ b/tests/oauth2/rfc6749/endpoints/test_client_authentication.py
@@ -32,6 +32,8 @@ class ClientAuthenticationTest(TestCase):
def setUp(self):
self.validator = mock.MagicMock(spec=RequestValidator)
+ self.validator.is_pkce_required.return_value = False
+ self.validator.get_code_challenge.return_value = None
self.validator.get_default_redirect_uri.return_value = 'http://i.b./path'
self.web = WebApplicationServer(self.validator,
token_generator=self.inspect_client)
diff --git a/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py b/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py
index 50c2956..1a2f66b 100644
--- a/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py
+++ b/tests/oauth2/rfc6749/endpoints/test_credentials_preservation.py
@@ -24,6 +24,7 @@ class PreservationTest(TestCase):
def setUp(self):
self.validator = mock.MagicMock(spec=RequestValidator)
self.validator.get_default_redirect_uri.return_value = self.DEFAULT_REDIRECT_URI
+ self.validator.get_code_challenge.return_value = None
self.validator.authenticate_client.side_effect = self.set_client
self.web = WebApplicationServer(self.validator)
self.mobile = MobileApplicationServer(self.validator)
diff --git a/tests/oauth2/rfc6749/endpoints/test_error_responses.py b/tests/oauth2/rfc6749/endpoints/test_error_responses.py
index ef05c4d..a249cb1 100644
--- a/tests/oauth2/rfc6749/endpoints/test_error_responses.py
+++ b/tests/oauth2/rfc6749/endpoints/test_error_responses.py
@@ -24,6 +24,7 @@ class ErrorResponseTest(TestCase):
def setUp(self):
self.validator = mock.MagicMock(spec=RequestValidator)
self.validator.get_default_redirect_uri.return_value = None
+ self.validator.get_code_challenge.return_value = None
self.web = WebApplicationServer(self.validator)
self.mobile = MobileApplicationServer(self.validator)
self.legacy = LegacyApplicationServer(self.validator)
diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py
index 301e846..4813b46 100644
--- a/tests/oauth2/rfc6749/endpoints/test_metadata.py
+++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py
@@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals
from oauthlib.oauth2 import MetadataEndpoint
from oauthlib.oauth2 import TokenEndpoint
+from oauthlib.oauth2 import Server
from ....unittest import TestCase
@@ -13,6 +14,33 @@ class MetadataEndpointTest(TestCase):
"issuer": 'https://foo.bar'
}
+ def test_openid_oauth2_preconfigured(self):
+ default_claims = {
+ "issuer": 'https://foo.bar',
+ "authorization_endpoint": "https://foo.bar/authorize",
+ "revocation_endpoint": "https://foo.bar/revoke",
+ "introspection_endpoint": "https://foo.bar/introspect",
+ "token_endpoint": "https://foo.bar/token"
+ }
+ from oauthlib.oauth2 import Server as OAuth2Server
+ from oauthlib.openid import Server as OpenIDServer
+
+ endpoint = OAuth2Server(None)
+ metadata = MetadataEndpoint([endpoint], default_claims)
+ oauth2_claims = metadata.claims
+
+ endpoint = OpenIDServer(None)
+ metadata = MetadataEndpoint([endpoint], default_claims)
+ openid_claims = metadata.claims
+
+ # Pure OAuth2 Authorization Metadata are similar with OpenID but
+ # response_type not! (OIDC contains "id_token" and hybrid flows)
+ del oauth2_claims['response_types_supported']
+ del openid_claims['response_types_supported']
+
+ self.maxDiff = None
+ self.assertEqual(openid_claims, oauth2_claims)
+
def test_token_endpoint(self):
endpoint = TokenEndpoint(None, None, grant_types={"password": None})
metadata = MetadataEndpoint([endpoint], {
@@ -36,3 +64,63 @@ class MetadataEndpointTest(TestCase):
metadata = MetadataEndpoint([], self.metadata)
self.assertIn("issuer", metadata.claims)
self.assertEqual(metadata.claims["issuer"], 'https://foo.bar')
+
+ def test_server_metadata(self):
+ endpoint = Server(None)
+ metadata = MetadataEndpoint([endpoint], {
+ "issuer": 'https://foo.bar',
+ "authorization_endpoint": "https://foo.bar/authorize",
+ "introspection_endpoint": "https://foo.bar/introspect",
+ "revocation_endpoint": "https://foo.bar/revoke",
+ "token_endpoint": "https://foo.bar/token",
+ "jwks_uri": "https://foo.bar/certs",
+ "scopes_supported": ["email", "profile"]
+ })
+ expected_claims = {
+ "issuer": "https://foo.bar",
+ "authorization_endpoint": "https://foo.bar/authorize",
+ "introspection_endpoint": "https://foo.bar/introspect",
+ "revocation_endpoint": "https://foo.bar/revoke",
+ "token_endpoint": "https://foo.bar/token",
+ "jwks_uri": "https://foo.bar/certs",
+ "scopes_supported": ["email", "profile"],
+ "grant_types_supported": [
+ "authorization_code",
+ "password",
+ "client_credentials",
+ "refresh_token",
+ "implicit"
+ ],
+ "token_endpoint_auth_methods_supported": [
+ "client_secret_post",
+ "client_secret_basic"
+ ],
+ "response_types_supported": [
+ "code",
+ "token"
+ ],
+ "response_modes_supported": [
+ "query",
+ "fragment"
+ ],
+ "code_challenge_methods_supported": [
+ "plain",
+ "S256"
+ ],
+ "revocation_endpoint_auth_methods_supported": [
+ "client_secret_post",
+ "client_secret_basic"
+ ],
+ "introspection_endpoint_auth_methods_supported": [
+ "client_secret_post",
+ "client_secret_basic"
+ ]
+ }
+
+ def sort_list(claims):
+ for k in claims.keys():
+ claims[k] = sorted(claims[k])
+
+ sort_list(metadata.claims)
+ sort_list(expected_claims)
+ self.assertEqual(sorted(metadata.claims.items()), sorted(expected_claims.items()))
diff --git a/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py b/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py
index d30ec9d..e823286 100644
--- a/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py
+++ b/tests/oauth2/rfc6749/endpoints/test_resource_owner_association.py
@@ -46,6 +46,7 @@ class ResourceOwnerAssociationTest(TestCase):
def setUp(self):
self.validator = mock.MagicMock(spec=RequestValidator)
self.validator.get_default_redirect_uri.return_value = 'http://i.b./path'
+ self.validator.get_code_challenge.return_value = None
self.validator.authenticate_client.side_effect = self.set_client
self.web = WebApplicationServer(self.validator,
token_generator=self.inspect_client)
diff --git a/tests/oauth2/rfc6749/endpoints/test_scope_handling.py b/tests/oauth2/rfc6749/endpoints/test_scope_handling.py
index 8490c03..4f27963 100644
--- a/tests/oauth2/rfc6749/endpoints/test_scope_handling.py
+++ b/tests/oauth2/rfc6749/endpoints/test_scope_handling.py
@@ -42,6 +42,7 @@ class TestScopeHandling(TestCase):
def setUp(self):
self.validator = mock.MagicMock(spec=RequestValidator)
self.validator.get_default_redirect_uri.return_value = TestScopeHandling.DEFAULT_REDIRECT_URI
+ self.validator.get_code_challenge.return_value = None
self.validator.authenticate_client.side_effect = self.set_client
self.server = Server(self.validator)
self.web = WebApplicationServer(self.validator)
diff --git a/tests/oauth2/rfc6749/grant_types/test_authorization_code.py b/tests/oauth2/rfc6749/grant_types/test_authorization_code.py
index acb23ac..00e2b6d 100644
--- a/tests/oauth2/rfc6749/grant_types/test_authorization_code.py
+++ b/tests/oauth2/rfc6749/grant_types/test_authorization_code.py
@@ -8,6 +8,7 @@ import mock
from oauthlib.common import Request
from oauthlib.oauth2.rfc6749 import errors
from oauthlib.oauth2.rfc6749.grant_types import AuthorizationCodeGrant
+from oauthlib.oauth2.rfc6749.grant_types import authorization_code
from oauthlib.oauth2.rfc6749.tokens import BearerToken
from ....unittest import TestCase
@@ -27,6 +28,8 @@ class AuthorizationCodeGrantTest(TestCase):
self.request.redirect_uri = 'https://a.b/cb'
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.authenticate_client.side_effect = self.set_client
self.auth = AuthorizationCodeGrant(request_validator=self.mock_validator)
@@ -200,3 +203,124 @@ class AuthorizationCodeGrantTest(TestCase):
self.mock_validator.confirm_redirect_uri.return_value = False
self.assertRaises(errors.MismatchingRedirectURIError,
self.auth.validate_token_request, self.request)
+
+ # PKCE validate_authorization_request
+ def test_pkce_challenge_missing(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.assertRaises(errors.MissingCodeChallengeError,
+ self.auth.validate_authorization_request, self.request)
+
+ def test_pkce_default_method(self):
+ for required in [True, False]:
+ self.mock_validator.is_pkce_required.return_value = required
+ self.request.code_challenge = "present"
+ _, ri = self.auth.validate_authorization_request(self.request)
+ self.assertIsNotNone(ri["request"].code_challenge_method)
+ self.assertEqual(ri["request"].code_challenge_method, "plain")
+
+ def test_pkce_wrong_method(self):
+ for required in [True, False]:
+ self.mock_validator.is_pkce_required.return_value = required
+ self.request.code_challenge = "present"
+ self.request.code_challenge_method = "foobar"
+ self.assertRaises(errors.UnsupportedCodeChallengeMethodError,
+ self.auth.validate_authorization_request, self.request)
+
+ # PKCE validate_token_request
+ def test_pkce_verifier_missing(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.assertRaises(errors.MissingCodeVerifierError,
+ self.auth.validate_token_request, self.request)
+
+ # PKCE validate_token_request
+ def test_pkce_required_verifier_missing_challenge_missing(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = None
+ self.mock_validator.get_code_challenge.return_value = None
+ self.assertRaises(errors.MissingCodeVerifierError,
+ self.auth.validate_token_request, self.request)
+
+ def test_pkce_required_verifier_missing_challenge_valid(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = None
+ self.mock_validator.get_code_challenge.return_value = "foo"
+ self.assertRaises(errors.MissingCodeVerifierError,
+ self.auth.validate_token_request, self.request)
+
+ def test_pkce_required_verifier_valid_challenge_missing(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = "foobar"
+ self.mock_validator.get_code_challenge.return_value = None
+ self.assertRaises(errors.InvalidGrantError,
+ self.auth.validate_token_request, self.request)
+
+ def test_pkce_required_verifier_valid_challenge_valid_method_valid(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = "foobar"
+ self.mock_validator.get_code_challenge.return_value = "foobar"
+ self.mock_validator.get_code_challenge_method.return_value = "plain"
+ self.auth.validate_token_request(self.request)
+
+ def test_pkce_required_verifier_invalid_challenge_valid_method_valid(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = "foobar"
+ self.mock_validator.get_code_challenge.return_value = "raboof"
+ self.mock_validator.get_code_challenge_method.return_value = "plain"
+ self.assertRaises(errors.InvalidGrantError,
+ self.auth.validate_token_request, self.request)
+
+ def test_pkce_required_verifier_valid_challenge_valid_method_wrong(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = "present"
+ self.mock_validator.get_code_challenge.return_value = "foobar"
+ self.mock_validator.get_code_challenge_method.return_value = "cryptic_method"
+ self.assertRaises(errors.ServerError,
+ self.auth.validate_token_request, self.request)
+
+ def test_pkce_verifier_valid_challenge_valid_method_missing(self):
+ self.mock_validator.is_pkce_required.return_value = True
+ self.request.code_verifier = "present"
+ self.mock_validator.get_code_challenge.return_value = "foobar"
+ self.mock_validator.get_code_challenge_method.return_value = None
+ self.assertRaises(errors.InvalidGrantError,
+ self.auth.validate_token_request, self.request)
+
+ def test_pkce_optional_verifier_valid_challenge_missing(self):
+ self.mock_validator.is_pkce_required.return_value = False
+ self.request.code_verifier = "present"
+ self.mock_validator.get_code_challenge.return_value = None
+ self.auth.validate_token_request(self.request)
+
+ def test_pkce_optional_verifier_missing_challenge_valid(self):
+ self.mock_validator.is_pkce_required.return_value = False
+ self.request.code_verifier = None
+ self.mock_validator.get_code_challenge.return_value = "foobar"
+ self.assertRaises(errors.MissingCodeVerifierError,
+ self.auth.validate_token_request, self.request)
+
+ # PKCE functions
+ def test_wrong_code_challenge_method_plain(self):
+ self.assertFalse(authorization_code.code_challenge_method_plain("foo", "bar"))
+
+ def test_correct_code_challenge_method_plain(self):
+ self.assertTrue(authorization_code.code_challenge_method_plain("foo", "foo"))
+
+ def test_wrong_code_challenge_method_s256(self):
+ self.assertFalse(authorization_code.code_challenge_method_s256("foo", "bar"))
+
+ def test_correct_code_challenge_method_s256(self):
+ # "abcd" as verifier gives a '+' to base64
+ self.assertTrue(
+ authorization_code.code_challenge_method_s256("abcd",
+ "iNQmb9TmM40TuEX88olXnSCciXgjuSF9o-Fhk28DFYk")
+ )
+ # "/" as verifier gives a '/' and '+' to base64
+ self.assertTrue(
+ authorization_code.code_challenge_method_s256("/",
+ "il7asoJjJEMhngUeSt4tHVu8Zxx4EFG_FDeJfL3-oPE")
+ )
+ # Example from PKCE RFCE
+ self.assertTrue(
+ authorization_code.code_challenge_method_s256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
+ "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+ )
diff --git a/tests/oauth2/rfc6749/test_server.py b/tests/oauth2/rfc6749/test_server.py
index bc7a2b7..b623a9b 100644
--- a/tests/oauth2/rfc6749/test_server.py
+++ b/tests/oauth2/rfc6749/test_server.py
@@ -23,6 +23,7 @@ class AuthorizationEndpointTest(TestCase):
def setUp(self):
self.mock_validator = mock.MagicMock()
+ self.mock_validator.get_code_challenge.return_value = None
self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock())
auth_code = AuthorizationCodeGrant(
request_validator=self.mock_validator)
@@ -117,6 +118,7 @@ class TokenEndpointTest(TestCase):
self.mock_validator = mock.MagicMock()
self.mock_validator.authenticate_client.side_effect = set_user
+ self.mock_validator.get_code_challenge.return_value = None
self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock())
auth_code = AuthorizationCodeGrant(
request_validator=self.mock_validator)
@@ -218,6 +220,7 @@ class SignedTokenEndpointTest(TestCase):
return True
self.mock_validator = mock.MagicMock()
+ self.mock_validator.get_code_challenge.return_value = None
self.mock_validator.authenticate_client.side_effect = set_user
self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock())
diff --git a/tests/openid/connect/core/endpoints/test_claims_handling.py b/tests/openid/connect/core/endpoints/test_claims_handling.py
index d5908a8..270ef69 100644
--- a/tests/openid/connect/core/endpoints/test_claims_handling.py
+++ b/tests/openid/connect/core/endpoints/test_claims_handling.py
@@ -56,6 +56,7 @@ class TestClaimsHandling(TestCase):
def setUp(self):
self.validator = mock.MagicMock(spec=RequestValidator)
+ self.validator.get_code_challenge.return_value = None
self.validator.get_default_redirect_uri.return_value = TestClaimsHandling.DEFAULT_REDIRECT_URI
self.validator.authenticate_client.side_effect = self.set_client
diff --git a/tests/openid/connect/core/grant_types/test_authorization_code.py b/tests/openid/connect/core/grant_types/test_authorization_code.py
index 9bbe7fb..c3c7824 100644
--- a/tests/openid/connect/core/grant_types/test_authorization_code.py
+++ b/tests/openid/connect/core/grant_types/test_authorization_code.py
@@ -43,6 +43,7 @@ class OpenIDAuthCodeTest(TestCase):
self.mock_validator = mock.MagicMock()
self.mock_validator.authenticate_client.side_effect = self.set_client
+ self.mock_validator.get_code_challenge.return_value = None
self.mock_validator.get_id_token.side_effect = get_id_token_mock
self.auth = AuthorizationCodeGrant(request_validator=self.mock_validator)
diff --git a/tests/openid/connect/core/grant_types/test_implicit.py b/tests/openid/connect/core/grant_types/test_implicit.py
index c369bb6..7ab198a 100644
--- a/tests/openid/connect/core/grant_types/test_implicit.py
+++ b/tests/openid/connect/core/grant_types/test_implicit.py
@@ -120,6 +120,7 @@ class OpenIDHybridCodeIdTokenTest(OpenIDAuthCodeTest):
def setUp(self):
super(OpenIDHybridCodeIdTokenTest, self).setUp()
+ self.mock_validator.get_code_challenge.return_value = None
self.request.response_type = 'code id_token'
self.auth = HybridGrant(request_validator=self.mock_validator)
token = 'MOCKED_TOKEN'
@@ -131,6 +132,7 @@ class OpenIDHybridCodeIdTokenTokenTest(OpenIDAuthCodeTest):
def setUp(self):
super(OpenIDHybridCodeIdTokenTokenTest, self).setUp()
+ self.mock_validator.get_code_challenge.return_value = None
self.request.response_type = 'code id_token token'
self.auth = HybridGrant(request_validator=self.mock_validator)
token = 'MOCKED_TOKEN'
diff --git a/tests/openid/connect/core/test_server.py b/tests/openid/connect/core/test_server.py
index a83f22d..ffab7b0 100644
--- a/tests/openid/connect/core/test_server.py
+++ b/tests/openid/connect/core/test_server.py
@@ -21,6 +21,7 @@ class AuthorizationEndpointTest(TestCase):
def setUp(self):
self.mock_validator = mock.MagicMock()
+ self.mock_validator.get_code_challenge.return_value = None
self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock())
auth_code = AuthorizationCodeGrant(request_validator=self.mock_validator)
auth_code.save_authorization_code = mock.MagicMock()
@@ -122,6 +123,7 @@ class TokenEndpointTest(TestCase):
self.mock_validator = mock.MagicMock()
self.mock_validator.authenticate_client.side_effect = set_user
+ self.mock_validator.get_code_challenge.return_value = None
self.addCleanup(setattr, self, 'mock_validator', mock.MagicMock())
auth_code = AuthorizationCodeGrant(
request_validator=self.mock_validator)