Skip to content

Commit 9b9a11b

Browse files
committed
add PKCE support
1 parent e50b3f9 commit 9b9a11b

3 files changed

Lines changed: 79 additions & 11 deletions

File tree

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
description='OpenID Connect Provider (OP) library in Python.',
1313
install_requires=[
1414
'oic >= 1.2.1',
15+
'nacl',
1516
'pymongo'
1617
]
1718
)

src/pyop/message.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from oic.oauth2.message import SINGLE_OPTIONAL_STRING
2+
from oic.oic import message
3+
4+
class AccessTokenRequest(message.Message):
5+
c_param = message.AccessTokenRequest.c_param.copy()
6+
c_param.update(
7+
{
8+
'code_verifier': SINGLE_OPTIONAL_STRING
9+
}
10+
)
11+
12+
class AuthorizationRequest(message.Message):
13+
c_param = message.AuthorizationRequest.c_param.copy()
14+
c_param.update(
15+
{
16+
'code_challenge': SINGLE_OPTIONAL_STRING,
17+
'code_challenge_method': SINGLE_OPTIONAL_STRING
18+
}
19+
)
20+
21+
c_allowed_values = message.AuthorizationRequest.c_param.copy()
22+
c_allowed_values.update(
23+
{
24+
"code_challenge_method": [
25+
"plain",
26+
"S256"
27+
]
28+
}
29+
)

src/pyop/provider.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
from urllib.parse import parse_qsl
77
from urllib.parse import urlparse
88

9+
import nacl.hash
10+
from nacl.encoding import URLSafeBase64Encoder
911
from jwkest import jws
1012
from oic import rndstr
1113
from oic.exception import MessageException
1214
from oic.oic import PREFERENCE2PROVIDER
1315
from oic.oic import scope2claims
14-
from oic.oic.message import AccessTokenRequest
1516
from oic.oic.message import AccessTokenResponse
16-
from oic.oic.message import AuthorizationRequest
1717
from oic.oic.message import AuthorizationResponse
1818
from oic.oic.message import EndSessionRequest
1919
from oic.oic.message import EndSessionResponse
@@ -24,6 +24,8 @@
2424
from oic.oic.message import RegistrationRequest
2525
from oic.oic.message import RegistrationResponse
2626

27+
from .message import AuthorizationRequest
28+
from .message import AccessTokenRequest
2729
from .access_token import extract_bearer_token_from_http_request
2830
from .client_authentication import verify_client_authentication
2931
from .exceptions import AuthorizationError
@@ -328,6 +330,49 @@ def handle_token_request(self, request_body, # type: str
328330
raise InvalidTokenRequest('grant_type \'{}\' unknown'.format(token_request['grant_type']), token_request,
329331
oauth_error='unsupported_grant_type')
330332

333+
def _compute_code_challenge(self, code_verifier):
334+
"""
335+
Given a code verifier compute the code_challenge. This code_challenge is computed as defined (https://datatracker.ietf.org/doc/html/rfc7636#section-4.2):
336+
337+
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))).
338+
339+
This shows that the SHA256 of the ascii encoded code_verifier is URLSafe base64 encoded. We have adjusted the encoding to the ISO_8859_1 encoding,
340+
conform to the AppAuth SDK for Android and IOS. Moreover, we remove the base64 padding (=).
341+
342+
:param code_verifier: the code verifier to transform to the Code Challenge
343+
"""
344+
verifier_hash = nacl.hash.sha256(code_verifier.encode('ISO_8859_1'), encoder=URLSafeBase64Encoder)
345+
return verifier_hash.decode().replace('=', '')
346+
347+
def _PKCE_verify(self, token_request, authentication_request):
348+
"""
349+
Verify that the given code_verifier complies with the initially supplied code_challenge.
350+
351+
Only supports the SHA256 code challenge method, plaintext is regarded as unsafe.
352+
353+
:param cc_cm: the initially supplied Code Challenge Code challenge Method dictionary
354+
:param code_verifier: the code_verfier to check against the code challenge.
355+
:returns: whether the code_verifier is what was expected given the cc_cm
356+
"""
357+
code_challenge_method = authentication_request['code_challenge_method']
358+
if code_challenge_method == 'plain':
359+
return authentication_request['code_challenge'] == token_request['code_verifier']
360+
361+
code_challenge = self._compute_code_challenge(token_request['code_verifier'])
362+
return code_challenge == authentication_request['code_challenge']
363+
364+
def _verify_code_exchange_req(self, token_request, authentication_request):
365+
if token_request['client_id'] != authentication_request['client_id']:
366+
logger.info('Authorization code \'%s\' belonging to \'%s\' was used by \'%s\'',
367+
token_request['code'], authentication_request['client_id'], token_request['client_id'])
368+
raise InvalidAuthorizationCode('{} unknown'.format(token_request['code']))
369+
if token_request['redirect_uri'] != authentication_request['redirect_uri']:
370+
raise InvalidTokenRequest('Invalid redirect_uri: {} != {}'.format(token_request['redirect_uri'],
371+
authentication_request['redirect_uri']),
372+
token_request)
373+
if not self._PKCE_verify(token_request, authentication_request):
374+
raise InvalidTokenRequest('Unexpected Code Verifier: {}'.format(authentication_request['code_challenge']))
375+
331376
def _do_code_exchange(self, request, # type: Dict[str, str]
332377
extra_id_token_claims=None
333378
# type: Optional[Union[Mapping[str, Union[str, List[str]]], Callable[[str, str], Mapping[str, Union[str, List[str]]]]]
@@ -351,14 +396,7 @@ def _do_code_exchange(self, request, # type: Dict[str, str]
351396

352397
authentication_request = self.authz_state.get_authorization_request_for_code(token_request['code'])
353398

354-
if token_request['client_id'] != authentication_request['client_id']:
355-
logger.info('Authorization code \'%s\' belonging to \'%s\' was used by \'%s\'',
356-
token_request['code'], authentication_request['client_id'], token_request['client_id'])
357-
raise InvalidAuthorizationCode('{} unknown'.format(token_request['code']))
358-
if token_request['redirect_uri'] != authentication_request['redirect_uri']:
359-
raise InvalidTokenRequest('Invalid redirect_uri: {} != {}'.format(token_request['redirect_uri'],
360-
authentication_request['redirect_uri']),
361-
token_request)
399+
self._verify_code_exchange_req(token_request, authentication_request)
362400

363401
sub = self.authz_state.get_subject_identifier_for_code(token_request['code'])
364402
user_id = self.authz_state.get_user_id_for_subject_identifier(sub)
@@ -393,7 +431,7 @@ def _do_token_refresh(self, request):
393431
Handles a token request for refreshing an access token (grant_type=refresh_token).
394432
:param request: parsed http request parameters
395433
:return: a token response containing a new Access Token and possibly a new Refresh Token
396-
:raise InvalidTokenRequest: if the token request is invalid
434+
:raise InvalidTtoken_requestokenRequest: if the token request is invalid
397435
"""
398436
token_request = RefreshAccessTokenRequest().from_dict(request)
399437
try:

0 commit comments

Comments
 (0)