From 01b3c4c20178b292d470eead153b91feaa05c057 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Thu, 29 Nov 2018 16:43:00 +0100 Subject: Initial OAuth2.0/PKCE Provider support --- docs/feature_matrix.rst | 9 +- docs/oauth2/server.rst | 11 ++ oauthlib/common.py | 3 + oauthlib/oauth2/rfc6749/errors.py | 32 ++++++ .../rfc6749/grant_types/authorization_code.py | 110 ++++++++++++++++++ oauthlib/oauth2/rfc6749/request_validator.py | 108 ++++++++++++++++-- .../endpoints/test_client_authentication.py | 2 + .../endpoints/test_credentials_preservation.py | 1 + .../rfc6749/endpoints/test_error_responses.py | 1 + .../endpoints/test_resource_owner_association.py | 1 + .../rfc6749/endpoints/test_scope_handling.py | 1 + .../rfc6749/grant_types/test_authorization_code.py | 124 +++++++++++++++++++++ tests/oauth2/rfc6749/test_server.py | 3 + .../connect/core/endpoints/test_claims_handling.py | 1 + .../core/grant_types/test_authorization_code.py | 1 + .../connect/core/grant_types/test_implicit.py | 2 + tests/openid/connect/core/test_server.py | 2 + 17 files changed, 397 insertions(+), 15 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..eca363b 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=100) + 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/errors.py b/oauthlib/oauth2/rfc6749/errors.py index 7ead3d4..f7fac5c 100644 --- a/oauthlib/oauth2/rfc6749/errors.py +++ b/oauthlib/oauth2/rfc6749/errors.py @@ -180,6 +180,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. @@ -196,6 +216,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. diff --git a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py index 8ebae49..0df7c6c 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. @@ -350,6 +414,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) @@ -422,6 +500,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) @@ -449,3 +554,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/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_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) -- cgit v1.2.1 From cf3cf407be774405f66188219eb1653c723e294b Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Fri, 30 Nov 2018 15:12:04 +0100 Subject: Add OAuth2 Provider Server Metadata for PKCE. --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 6d77b9f..6873334 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -104,6 +104,8 @@ class MetadataEndpoint(BaseEndpoint): 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"]: + claims.setdefault("code_challenge_methods_supported", + list(endpoint._response_types["code"]._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) -- cgit v1.2.1 From 6bd865b36dc64caaa8aab9788742c9d54ce81c4d Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Fri, 30 Nov 2018 15:15:50 +0100 Subject: Add Server metadata test and fix metadata. Fix grant_types_supported which must include "implicit" even if it is not a grant_type in oauthlib sense. Removed internal "none" field value from the list of response_types. --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 12 ++++-- tests/oauth2/rfc6749/endpoints/test_metadata.py | 53 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 6873334..84ddf8f 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -89,17 +89,19 @@ 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())) + self._grant_types += list(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"]) + 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) @@ -183,6 +185,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) @@ -192,4 +195,7 @@ class MetadataEndpoint(BaseEndpoint): self.validate_metadata_revocation(claims, endpoint) if isinstance(endpoint, IntrospectEndpoint): self.validate_metadata_introspection(claims, endpoint) + + claims.setdefault("grant_types_supported", self._grant_types) + self.validate_metadata(claims, "grant_types_supported", is_list=True) return claims diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index 301e846..5174b2d 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 @@ -36,3 +37,55 @@ 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"] + }) + self.assertEqual(metadata.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" + ] + }) -- cgit v1.2.1 From d7891e70a7593bc428510f66d8c1e60ff3731c30 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Fri, 30 Nov 2018 15:36:57 +0100 Subject: Sort dict and list in dict values for py27/36 compat --- tests/oauth2/rfc6749/endpoints/test_metadata.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index 5174b2d..875316a 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -49,7 +49,7 @@ class MetadataEndpointTest(TestCase): "jwks_uri": "https://foo.bar/certs", "scopes_supported": ["email", "profile"] }) - self.assertEqual(metadata.claims, { + expected_claims = { "issuer": "https://foo.bar", "authorization_endpoint": "https://foo.bar/authorize", "introspection_endpoint": "https://foo.bar/introspect", @@ -88,4 +88,12 @@ class MetadataEndpointTest(TestCase): "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())) -- cgit v1.2.1 From d0640038a84f60b37864cf585fd764f9ee34b1c4 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Thu, 13 Dec 2018 11:00:15 +0100 Subject: challenge can have a length of 128 when using maximum size of verifier+plain. --- docs/oauth2/server.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oauth2/server.rst b/docs/oauth2/server.rst index eca363b..6c065c5 100644 --- a/docs/oauth2/server.rst +++ b/docs/oauth2/server.rst @@ -253,7 +253,7 @@ the token. .. code-block:: python - challenge = django.db.models.CharField(max_length=100) + challenge = django.db.models.CharField(max_length=128) challenge_method = django.db.models.CharField(max_length=6) -- cgit v1.2.1 From 1a7be4eebb11cd5224c3b6eaf1782e8add5bd8d9 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Thu, 13 Dec 2018 16:05:29 +0100 Subject: Replace temporary list by using clearer "extend" method --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 84ddf8f..fe6545f 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -89,7 +89,7 @@ class MetadataEndpoint(BaseEndpoint): raise ValueError("array {}: {} must contains only string (not {})".format(key, array[key], elem)) def validate_metadata_token(self, claims, endpoint): - self._grant_types += list(endpoint._grant_types.keys()) + 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, "token_endpoint_auth_methods_supported", is_list=True) -- cgit v1.2.1 From 6dcde73a81d6cbc718ca9ca7f9170a28fc1b5e34 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Thu, 13 Dec 2018 16:31:03 +0100 Subject: Add details on grant_type & implicit special case. --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index fe6545f..c2d5918 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -89,6 +89,12 @@ class MetadataEndpoint(BaseEndpoint): raise ValueError("array {}: {} must contains only string (not {})".format(key, array[key], elem)) def validate_metadata_token(self, claims, endpoint): + """ + 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"]) @@ -100,6 +106,10 @@ class MetadataEndpoint(BaseEndpoint): 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, at 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") @@ -196,6 +206,8 @@ class MetadataEndpoint(BaseEndpoint): 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 -- cgit v1.2.1 From ac23d0973b441cd2afdcabe24f474147eada8242 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Thu, 13 Dec 2018 17:16:23 +0100 Subject: Fixed typo --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index c2d5918..45cf110 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -108,7 +108,7 @@ class MetadataEndpoint(BaseEndpoint): 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, at such, we have to add it explicitly to + # 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") -- cgit v1.2.1 From 7be2769bcfefc5db9b54603dbbbcd4db0d237216 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Fri, 14 Dec 2018 13:05:50 +0100 Subject: Fix issue when using Metadata Endpoint with OIDC PreConfigured server. --- .../connect/core/endpoints/pre_configured.py | 6 +++-- tests/oauth2/rfc6749/endpoints/test_metadata.py | 27 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) 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_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index 875316a..4813b46 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -14,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], { -- cgit v1.2.1 From f6b6d1460755c31d522e5e01be28bfd5b1f9da33 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Fri, 14 Dec 2018 13:25:43 +0100 Subject: Fixed OAuth2 Metadata when using PKCE and OIDC.Server --- oauthlib/oauth2/rfc6749/endpoints/metadata.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index 45cf110..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__) @@ -116,8 +117,12 @@ class MetadataEndpoint(BaseEndpoint): 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(endpoint._response_types["code"]._code_challenge_methods.keys())) + 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) -- cgit v1.2.1