Skip to content

Commit

Permalink
Initial OAuth2.0/PKCE Provider support
Browse files Browse the repository at this point in the history
  • Loading branch information
JonathanHuot committed Nov 29, 2018
1 parent 719a5f3 commit 01b3c4c
Show file tree
Hide file tree
Showing 17 changed files with 397 additions and 15 deletions.
9 changes: 6 additions & 3 deletions docs/feature_matrix.rst
Expand Up @@ -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
Expand All @@ -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
11 changes: 11 additions & 0 deletions docs/oauth2/server.rst
Expand Up @@ -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
------------------------

Expand Down
3 changes: 3 additions & 0 deletions oauthlib/common.py
Expand Up @@ -397,6 +397,9 @@ def __init__(self, uri, http_method='GET', body=None, headers=None,
"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,
Expand Down
32 changes: 32 additions & 0 deletions oauthlib/oauth2/rfc6749/errors.py
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
110 changes: 110 additions & 0 deletions oauthlib/oauth2/rfc6749/grant_types/authorization_code.py
Expand Up @@ -5,6 +5,8 @@
"""
from __future__ import absolute_import, unicode_literals

import base64
import hashlib
import json
import logging

Expand All @@ -17,6 +19,52 @@
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`_
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -350,6 +414,20 @@ def validate_authorization_request(self, request):
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)
Expand Down Expand Up @@ -422,6 +500,33 @@ def validate_token_request(self, request):
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)
Expand Down Expand Up @@ -449,3 +554,8 @@ def validate_token_request(self, request):

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)
108 changes: 96 additions & 12 deletions oauthlib/oauth2/rfc6749/request_validator.py
Expand Up @@ -262,25 +262,29 @@ def save_authorization_code(self, client_id, code, request, *args, **kwargs):
"""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.
Expand Down Expand Up @@ -564,6 +568,11 @@ def validate_code(self, client_id, code, client, request, *args, **kwargs):
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``.
Expand Down Expand Up @@ -742,3 +751,78 @@ def validate_user_match(self, id_token_hint, scopes, claims, request):
- 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.')
2 changes: 2 additions & 0 deletions tests/oauth2/rfc6749/endpoints/test_client_authentication.py
Expand Up @@ -32,6 +32,8 @@ def inspect_client(self, request, refresh_token=False):

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)
Expand Down

0 comments on commit 01b3c4c

Please sign in to comment.