Skip to content

Commit 2f110dc

Browse files
Merge pull request #38 from maxxiefjv/add-PKCE-pyop
Add PKCE support Note that plaintext support is lacking, because it is considered unsafe.
2 parents 0ba83aa + 1c642b8 commit 2f110dc

8 files changed

+146
-27
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ user data and OpenID Connect claim names. Hence the underlying data source must
146146
same names as the [standard claims of OpenID Connect](http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims).
147147

148148
```python
149-
from oic.oic.message import AuthorizationRequest
149+
from pyop.message import AuthorizationRequest
150150

151151
from pyop.util import should_fragment_encode
152152

src/pyop/authz_state.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import uuid
44

55
from oic.extension.message import TokenIntrospectionResponse
6-
from oic.oic.message import AuthorizationRequest
76

7+
from .message import AuthorizationRequest
88
from .access_token import AccessToken
99
from .exceptions import InvalidAccessToken
1010
from .exceptions import InvalidAuthorizationCode
@@ -80,7 +80,7 @@ def __init__(self, subject_identifier_factory, authorization_code_db=None, acces
8080
self.subject_identifiers = subject_identifier_db if subject_identifier_db is not None else {}
8181

8282
def create_authorization_code(self, authorization_request, subject_identifier, scope=None):
83-
# type: (oic.oic.message.AuthorizationRequest, str, Optional[List[str]]) -> str
83+
# type: (AuthorizationRequest, str, Optional[List[str]]) -> str
8484
"""
8585
Creates an authorization code bound to the authorization request and the authenticated user identified
8686
by the subject identifier.
@@ -106,7 +106,7 @@ def create_authorization_code(self, authorization_request, subject_identifier, s
106106
return authorization_code
107107

108108
def create_access_token(self, authorization_request, subject_identifier, scope=None):
109-
# type: (oic.oic.message.AuthorizationRequest, str, Optional[List[str]]) -> se_leg_op.access_token.AccessToken
109+
# type: (AuthorizationRequest, str, Optional[List[str]]) -> se_leg_op.access_token.AccessToken
110110
"""
111111
Creates an access token bound to the authentication request and the authenticated user identified by the
112112
subject identifier.
@@ -315,22 +315,22 @@ def get_user_id_for_subject_identifier(self, subject_identifier):
315315
raise InvalidSubjectIdentifier('{} unknown'.format(subject_identifier))
316316

317317
def get_authorization_request_for_code(self, authorization_code):
318-
# type: (str) -> oic.oic.message.AuthorizationRequest
318+
# type: (str) -> AuthorizationRequest
319319
if authorization_code not in self.authorization_codes:
320320
raise InvalidAuthorizationCode('{} unknown'.format(authorization_code))
321321

322322
return AuthorizationRequest().from_dict(
323323
self.authorization_codes[authorization_code][self.KEY_AUTHORIZATION_REQUEST])
324324

325325
def get_authorization_request_for_access_token(self, access_token_value):
326-
# type: (str) -> oic.oic.message.AuthorizationRequest
326+
# type: (str) ->
327327
if access_token_value not in self.access_tokens:
328328
raise InvalidAccessToken('{} unknown'.format(access_token_value))
329329

330330
return AuthorizationRequest().from_dict(self.access_tokens[access_token_value][self.KEY_AUTHORIZATION_REQUEST])
331331

332332
def get_subject_identifier_for_code(self, authorization_code):
333-
# type: (str) -> oic.oic.message.AuthorizationRequest
333+
# type: (str) -> AuthorizationRequest
334334
if authorization_code not in self.authorization_codes:
335335
raise InvalidAuthorizationCode('{} unknown'.format(authorization_code))
336336

src/pyop/message.py

+29
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.AccessTokenRequest):
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.AuthorizationRequest):
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_allowed_values.copy()
22+
c_allowed_values.update(
23+
{
24+
"code_challenge_method": [
25+
"plain",
26+
"S256"
27+
]
28+
}
29+
)

src/pyop/provider.py

+61-15
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
from oic.exception import MessageException
1212
from oic.oic import PREFERENCE2PROVIDER
1313
from oic.oic import scope2claims
14-
from oic.oic.message import AccessTokenRequest
1514
from oic.oic.message import AccessTokenResponse
16-
from oic.oic.message import AuthorizationRequest
1715
from oic.oic.message import AuthorizationResponse
1816
from oic.oic.message import EndSessionRequest
1917
from oic.oic.message import EndSessionResponse
@@ -23,7 +21,10 @@
2321
from oic.oic.message import RefreshAccessTokenRequest
2422
from oic.oic.message import RegistrationRequest
2523
from oic.oic.message import RegistrationResponse
24+
from oic.extension.provider import Provider as OICProviderExtensions
2625

26+
from .message import AuthorizationRequest
27+
from .message import AccessTokenRequest
2728
from .access_token import extract_bearer_token_from_http_request
2829
from .client_authentication import verify_client_authentication
2930
from .exceptions import AuthorizationError
@@ -81,7 +82,7 @@ def __init__(self, signing_key, configuration_information, authz_state, clients,
8182
self.userinfo = userinfo
8283
self.id_token_lifetime = id_token_lifetime
8384

84-
self.authentication_request_validators = [] # type: List[Callable[[oic.oic.message.AuthorizationRequest], Boolean]]
85+
self.authentication_request_validators = [] # type: List[Callable[[AuthorizationRequest], Boolean]]
8586
self.authentication_request_validators.append(authorization_request_verify)
8687
self.authentication_request_validators.append(
8788
functools.partial(client_id_is_known, self))
@@ -114,7 +115,7 @@ def jwks(self):
114115
return {'keys': keys}
115116

116117
def parse_authentication_request(self, request_body, http_headers=None):
117-
# type: (str, Optional[Mapping[str, str]]) -> oic.oic.message.AuthorizationRequest
118+
# type: (str, Optional[Mapping[str, str]]) -> AuthorizationRequest
118119
"""
119120
Parses and verifies an authentication request.
120121
@@ -130,7 +131,7 @@ def parse_authentication_request(self, request_body, http_headers=None):
130131
logger.debug('parsed authentication_request: %s', auth_req)
131132
return auth_req
132133

133-
def authorize(self, authentication_request, # type: oic.oic.message.AuthorizationRequest
134+
def authorize(self, authentication_request, # type: AuthorizationRequest
134135
user_id, # type: str
135136
extra_id_token_claims=None
136137
# type: Optional[Union[Mapping[str, Union[str, List[str]]], Callable[[str, str], Mapping[str, Union[str, List[str]]]]]
@@ -216,7 +217,7 @@ def _create_subject_identifier(self, user_id, client_id, redirect_uri):
216217
return self.authz_state.get_subject_identifier(subject_type, user_id, sector_identifier)
217218

218219
def _get_requested_claims_in(self, authentication_request, response_method):
219-
# type (oic.oic.message.AuthorizationRequest, str) -> Mapping[str, Optional[Mapping[str, Union[str, List[str]]]]
220+
# type (AuthorizationRequest, str) -> Mapping[str, Optional[Mapping[str, Union[str, List[str]]]]
220221
"""
221222
Parses any claims requested using the 'claims' request parameter, see
222223
<a href="http://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter">
@@ -284,7 +285,7 @@ def _create_signed_id_token(self,
284285
return id_token.to_jwt([self.signing_key], alg)
285286

286287
def _check_subject_identifier_matches_requested(self, authentication_request, sub):
287-
# type (oic.message.AuthorizationRequest, str) -> None
288+
# type (AuthorizationRequest, str) -> None
288289
"""
289290
Verifies the subject identifier against any requested subject identifier using the claims request parameter.
290291
:param authentication_request: authentication request
@@ -328,6 +329,58 @@ def handle_token_request(self, request_body, # type: str
328329
raise InvalidTokenRequest('grant_type \'{}\' unknown'.format(token_request['grant_type']), token_request,
329330
oauth_error='unsupported_grant_type')
330331

332+
def _PKCE_verify(self,
333+
token_request, # type: AccessTokenRequest
334+
authentication_request # type: AuthorizationRequest
335+
):
336+
# type: (...) -> bool
337+
"""
338+
Verify that the given code_verifier complies with the initially supplied code_challenge.
339+
340+
Only supports the SHA256 code challenge method, plaintext is regarded as unsafe.
341+
342+
:param token_request: the token request containing the initially supplied code challenge and code_challenge method.
343+
:param authentication_request: the code_verfier to check against the code challenge.
344+
:returns: whether the code_verifier is what was expected given the cc_cm
345+
"""
346+
if not 'code_verifier' in token_request:
347+
return False
348+
349+
if not 'code_challenge_method' in authentication_request:
350+
raise InvalidTokenRequest("A code_challenge and code_verifier have been supplied"
351+
"but missing code_challenge_method in authentication_request", token_request)
352+
353+
# OIC Provider extension returns either a boolean or Response object containing an error. To support
354+
# stricter typing guidelines, return if True. Error handling support should be in encapsulating function.
355+
return OICProviderExtensions.verify_code_challenge(token_request['code_verifier'],
356+
authentication_request['code_challenge'], authentication_request['code_challenge_method']) == True
357+
358+
def _verify_code_exchange_req(self,
359+
token_request, # type: AccessTokenRequest
360+
authentication_request # type: AuthorizationRequest
361+
):
362+
# type: (...) -> None
363+
"""
364+
Verify that the code exchange request is valid. In order to be valid we validate
365+
the expected client and redirect_uri. Finally, if requested by the client, perform a
366+
PKCE check.
367+
368+
:param token_request: The request asking for a token given a code, and optionally a code_verifier
369+
:param authentication_request: The authentication request belonging to the provided code.
370+
:raises InvalidTokenRequest, InvalidAuthorizationCode: If request is invalid, throw a representing exception.
371+
"""
372+
if token_request['client_id'] != authentication_request['client_id']:
373+
logger.info('Authorization code \'%s\' belonging to \'%s\' was used by \'%s\'',
374+
token_request['code'], authentication_request['client_id'], token_request['client_id'])
375+
raise InvalidAuthorizationCode('{} unknown'.format(token_request['code']))
376+
if token_request['redirect_uri'] != authentication_request['redirect_uri']:
377+
raise InvalidTokenRequest('Invalid redirect_uri: {} != {}'.format(token_request['redirect_uri'],
378+
authentication_request['redirect_uri']),
379+
token_request)
380+
if 'code_challenge' in authentication_request and not self._PKCE_verify(token_request, authentication_request):
381+
raise InvalidTokenRequest('Unexpected Code Verifier: {}'.format(authentication_request['code_challenge']),
382+
token_request)
383+
331384
def _do_code_exchange(self, request, # type: Dict[str, str]
332385
extra_id_token_claims=None
333386
# type: Optional[Union[Mapping[str, Union[str, List[str]]], Callable[[str, str], Mapping[str, Union[str, List[str]]]]]
@@ -351,14 +404,7 @@ def _do_code_exchange(self, request, # type: Dict[str, str]
351404

352405
authentication_request = self.authz_state.get_authorization_request_for_code(token_request['code'])
353406

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)
407+
self._verify_code_exchange_req(token_request, authentication_request)
362408

363409
sub = self.authz_state.get_subject_identifier_for_code(token_request['code'])
364410
user_id = self.authz_state.get_user_id_for_subject_identifier(sub)

tests/pyop/test_authz_state.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
from unittest.mock import patch, Mock
55

66
import pytest
7-
from oic.oic.message import AuthorizationRequest
87

8+
from pyop.message import AuthorizationRequest
99
from pyop.authz_state import AccessToken, InvalidScope
1010
from pyop.authz_state import AuthorizationState
1111
from pyop.exceptions import InvalidSubjectIdentifier, InvalidAccessToken, InvalidAuthorizationCode, InvalidRefreshToken

tests/pyop/test_exceptions.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from urllib.parse import urlparse, parse_qsl
22

3-
from oic.oic.message import AuthorizationRequest
4-
3+
from pyop.message import AuthorizationRequest
54
from pyop.exceptions import InvalidAuthenticationRequest
65

76

tests/pyop/test_provider.py

+46-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
from oic import rndstr
1313
from oic.oauth2.message import MissingRequiredValue, MissingRequiredAttribute
1414
from oic.oic import PREFERENCE2PROVIDER
15-
from oic.oic.message import IdToken, AuthorizationRequest, ClaimsRequest, Claims, EndSessionRequest, EndSessionResponse
15+
from oic.oic.message import IdToken, ClaimsRequest, Claims, EndSessionRequest, EndSessionResponse
1616

17+
from pyop.message import AuthorizationRequest
1718
from pyop.access_token import BearerTokenError
1819
from pyop.authz_state import AuthorizationState
1920
from pyop.client_authentication import InvalidClientAuthentication
@@ -318,6 +319,20 @@ def test_code_exchange_request(self):
318319
assert_id_token_base_claims(response['id_token'], self.provider.signing_key, self.provider,
319320
self.authn_request_args)
320321

322+
@patch('time.time', MOCK_TIME)
323+
def test_pkce_code_exchange_request(self):
324+
self.authorization_code_exchange_request_args['code'] = self.create_authz_code(
325+
{
326+
"code_challenge": "_1f8tFjAtu6D1Df-GOyDPoMjCJdEvaSWsnqR6SLpzsw",
327+
"code_challenge_method": "S256"
328+
}
329+
)
330+
self.authorization_code_exchange_request_args['code_verifier'] = "SoOEDN-mZKNhw7Mc52VXxyiqTvFB3mod36MwPru253c"
331+
response = self.provider._do_code_exchange(self.authorization_code_exchange_request_args, None)
332+
assert response['access_token'] in self.provider.authz_state.access_tokens
333+
assert_id_token_base_claims(response['id_token'], self.provider.signing_key, self.provider,
334+
self.authn_request_args)
335+
321336
@patch('time.time', MOCK_TIME)
322337
def test_code_exchange_request_with_claims_requested_in_id_token(self):
323338
claims_req = {'claims': ClaimsRequest(id_token=Claims(email=None))}
@@ -374,6 +389,36 @@ def test_handle_token_request_reject_missing_grant_type(self):
374389
with pytest.raises(InvalidTokenRequest):
375390
self.provider.handle_token_request(urlencode(self.authorization_code_exchange_request_args))
376391

392+
def test_handle_token_request_reject_invalid_code_verifier(self):
393+
self.authorization_code_exchange_request_args['code'] = self.create_authz_code(
394+
{
395+
"code_challenge": "_1f8tFjAtu6D1Df-GOyDPoMjCJdEvaSWsnqR6SLpzsw=",
396+
"code_challenge_method": "S256"
397+
}
398+
)
399+
self.authorization_code_exchange_request_args['code_verifier'] = "ThiS Cer_tainly Ain't Valid"
400+
with pytest.raises(InvalidTokenRequest):
401+
self.provider.handle_token_request(urlencode(self.authorization_code_exchange_request_args))
402+
403+
def test_handle_token_request_reject_unsynced_requests(self):
404+
self.authorization_code_exchange_request_args['code'] = self.create_authz_code(
405+
{
406+
"code_challenge": "_1f8tFjAtu6D1Df-GOyDPoMjCJdEvaSWsnqR6SLpzsw=",
407+
"code_challenge_method": "S256"
408+
}
409+
)
410+
with pytest.raises(InvalidTokenRequest):
411+
self.provider.handle_token_request(urlencode(self.authorization_code_exchange_request_args))
412+
413+
def test_handle_token_request_reject_missing_code_challenge_method(self):
414+
self.authorization_code_exchange_request_args['code'] = self.create_authz_code(
415+
{
416+
"code_challenge": "_1f8tFjAtu6D1Df-GOyDPoMjCJdEvaSWsnqR6SLpzsw=",
417+
}
418+
)
419+
with pytest.raises(InvalidTokenRequest):
420+
self.provider.handle_token_request(urlencode(self.authorization_code_exchange_request_args))
421+
377422
def test_refresh_request(self):
378423
self.provider.authz_state = AuthorizationState(HashBasedSubjectIdentifierFactory('salt'),
379424
refresh_token_lifetime=600)

tests/pyop/test_util.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
2-
from oic.oic.message import AuthorizationRequest
32

3+
from pyop.message import AuthorizationRequest
44
from pyop.util import should_fragment_encode
55

66

0 commit comments

Comments
 (0)