Skip to content

Commit c19aa3d

Browse files
authored
Merge pull request #23 from sbrunner/allows-pkce
2 parents c140769 + a335eb2 commit c19aa3d

File tree

6 files changed

+195
-3
lines changed

6 files changed

+195
-3
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,6 @@ The list of [OpenID specifications](https://openid.net/developers/specs/) can be
8282

8383
- ✔️ Full [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
8484

85+
- ✔️ Full [Proof Key for Code Exchange (PKCE)](https://www.rfc-editor.org/rfc/rfc7636)
86+
8587
- ✔️ Full [OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662)

docs/usage.rst

+36
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,39 @@ It should be interpreted as pseudocode without any specific web or application f
103103
token_response = client.authorization_code_flow.handle_authentication_result(current_url)
104104
# token_response now contains access and id tokens
105105
...
106+
107+
Proof Key for Code Exchange (PKCE)
108+
----------------------------------
109+
110+
The Proof Key for Code Exchange (PKCE) is a security extension to OAuth2.0 that is used to prevent authorization code injection attacks.
111+
112+
The PKCE extension is used in the authorization code flow.
113+
114+
Example usage of PKCE::
115+
116+
from simple_openid_connect import pkce
117+
118+
client = OpenidClient.from_issuer_url(
119+
url="https://provider.example.com/openid",
120+
authentication_redirect_uri="https://myapp.example.com/login-callback",
121+
client_id="client-id",
122+
)
123+
code_verifier, code_challenge = pkce.generate_pkce_pair()
124+
125+
def on_login():
126+
# this method should be called when the user wants to log in
127+
# it returns an HTTP redirect to the Openid provider
128+
return HttpRedirect(to=client.authorization_code_flow.start_authentication(
129+
code_challenge=code_challenge,
130+
code_challenge_method="S256"
131+
))
132+
133+
def on_login_callback(current_url):
134+
token_response = client.authorization_code_flow.handle_authentication_result(
135+
current_url,
136+
code_verifier=code_verifier,
137+
code_challenge=code_challenge,
138+
code_challenge_method="S256",
139+
)
140+
# token_response now contains access and id tokens
141+
...

src/simple_openid_connect/data.py

+15
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ class AuthenticationRequest(OpenidBaseModel):
440440
acr_values: Optional[List[str]] = None
441441
"OPTIONAL. Requested Authentication Context Class Reference values Space-separated string that specifies the acr values that the Authorization Server is being requested to use for processing this Authentication Request, with the values appearing in order of preference The Authentication Context Class satisfied by the authentication performed is returned as the acr Claim Value, as specified in Section 2 The acr Claim is requested as a Voluntary Claim by this parameter."
442442

443+
code_challenge: Optional[str] = None
444+
"OPTIONAL. Code Challenge. This parameter is intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], to be used with code_challenge_method."
445+
446+
code_challenge_method: Optional[str] = None
447+
"OPTIONAL. Code Challenge Method. This parameter is intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], to be used with code_challenge."
448+
443449

444450
class AuthenticationSuccessResponse(OpenidBaseModel):
445451
"""
@@ -575,6 +581,15 @@ class TokenRequest(OpenidBaseModel):
575581
scope: Optional[str] = None
576582
"REQUIRED, if grant type is 'password'. The scope requested by the application"
577583

584+
code_verifier: Optional[str] = None
585+
"OPTIONAL. Code Verifier. This parameter is intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], to be used with code_challenge and code_challenge_method."
586+
587+
code_challenge: Optional[str] = None
588+
"OPTIONAL. Code Challenge. This parameter is intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], to be used with code_verifier, code_challenge_method."
589+
590+
code_challenge_method: Optional[str] = None
591+
"OPTIONAL. Code Challenge Method. This parameter is intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], to be used with code_verifier, code_challenge."
592+
578593
@model_validator(mode="before")
579594
@classmethod
580595
def _validate_required_based_on_grant_type(

src/simple_openid_connect/flows/authorization_code_flow/__init__.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"""
88
import copy
99
import logging
10-
from typing import Literal, Union
10+
from typing import Literal, Optional, Union
1111

1212
import requests
1313
from furl import furl
@@ -27,7 +27,12 @@
2727

2828

2929
def start_authentication(
30-
authorization_endpoint: str, scope: str, client_id: str, redirect_uri: str
30+
authorization_endpoint: str,
31+
scope: str,
32+
client_id: str,
33+
redirect_uri: str,
34+
code_challenge: Optional[str] = None,
35+
code_challenge_method: Optional[str] = None,
3136
) -> str:
3237
"""
3338
Start the authentication process by constructing an appropriate :class:`AuthenticationRequest`, serializing it and
@@ -40,6 +45,8 @@ def start_authentication(
4045
client_id=client_id,
4146
redirect_uri=redirect_uri,
4247
response_type="code",
48+
code_challenge=code_challenge,
49+
code_challenge_method=code_challenge_method,
4350
)
4451
return request.encode_url(authorization_endpoint)
4552

@@ -49,6 +56,9 @@ def handle_authentication_result(
4956
token_endpoint: str,
5057
client_authentication: ClientAuthenticationMethod,
5158
redirect_uri: Union[Literal["auto"], str] = "auto",
59+
code_verifier: Optional[str] = None,
60+
code_challenge: Optional[str] = None,
61+
code_challenge_method: Optional[str] = None,
5262
) -> Union[TokenSuccessResponse, TokenErrorResponse]:
5363
"""
5464
Handle an authentication result that is communicated to the RP in form of the user agents current url after having started an authentication process via :func:`start_authentication`.
@@ -87,6 +97,9 @@ def handle_authentication_result(
8797
authentication_response=auth_response_msg,
8898
redirect_uri=redirect_uri,
8999
client_authentication=client_authentication,
100+
code_verifier=code_verifier,
101+
code_challenge=code_challenge,
102+
code_challenge_method=code_challenge_method,
90103
)
91104

92105

@@ -95,6 +108,9 @@ def exchange_code_for_tokens(
95108
authentication_response: AuthenticationSuccessResponse,
96109
redirect_uri: str,
97110
client_authentication: ClientAuthenticationMethod,
111+
code_verifier: Optional[str] = None,
112+
code_challenge: Optional[str] = None,
113+
code_challenge_method: Optional[str] = None,
98114
) -> Union[TokenSuccessResponse, TokenErrorResponse]:
99115
"""
100116
Exchange a received code for access, refresh and id tokens.
@@ -116,6 +132,9 @@ def exchange_code_for_tokens(
116132
redirect_uri=redirect_uri,
117133
client_id=client_authentication.client_id,
118134
grant_type="authorization_code",
135+
code_verifier=code_verifier,
136+
code_challenge=code_challenge,
137+
code_challenge_method=code_challenge_method,
119138
)
120139
response = requests.post(
121140
token_endpoint,

src/simple_openid_connect/flows/authorization_code_flow/client.py

+19-1
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,18 @@ class AuthorizationCodeFlowClient:
2727
def __init__(self, base_client: "OpenidClient"):
2828
self._base_client = base_client
2929

30-
def start_authentication(self) -> str:
30+
def start_authentication(
31+
self,
32+
code_challenge: Optional[str] = None,
33+
code_challenge_method: Optional[str] = None,
34+
) -> str:
3135
"""
3236
Start the authentication process by constructing an appropriate :class:`AuthenticationRequest`, serializing it and
3337
returning a which the end user now needs to visit.
3438
39+
:param code_challenge: The code challenge intended for use with Proof Key for Code Exchange (PKCE) [RFC7636].
40+
:param code_challenge_method: The code challenge method intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], typically "S256" or "plain".
41+
3542
:raises ImpossibleOperationError: If the client has no redirect_uri configured and therefore cannot perform this operation.
3643
3744
:returns: A URL to which the user agent should be redirected
@@ -48,12 +55,17 @@ def start_authentication(self) -> str:
4855
self._base_client.scope,
4956
self._base_client.client_auth.client_id,
5057
redirect_uri.tostr(),
58+
code_challenge=code_challenge,
59+
code_challenge_method=code_challenge_method,
5160
)
5261

5362
def handle_authentication_result(
5463
self,
5564
current_url: str,
5665
additional_redirect_args: Optional[Mapping[str, str]] = None,
66+
code_verifier: Optional[str] = None,
67+
code_challenge: Optional[str] = None,
68+
code_challenge_method: Optional[str] = None,
5769
) -> Union[TokenSuccessResponse, TokenErrorResponse]:
5870
"""
5971
Handle an authentication result that is communicated to the RP in form of the user agents current url after having started an authentication process via :func:`start_authentication`.
@@ -62,6 +74,9 @@ def handle_authentication_result(
6274
The authentication result should be encoded into this url by the authorization server.
6375
:param additional_redirect_args: Additional URL parameters that were added to the redirect uri.
6476
They are probably still present in `current_url` but since they could be of any shape, no attempt is made here to automatically reconstruct them.
77+
:param code_verifier: The code verifier intended for use with Proof Key for Code Exchange (PKCE) [RFC7636].
78+
:param code_challenge: The code challenge intended for use with Proof Key for Code Exchange (PKCE) [RFC7636].
79+
:param code_challenge_method: The code challenge method intended for use with Proof Key for Code Exchange (PKCE) [RFC7636], typically "S256" or "plain".
6580
6681
:raises AuthenticationFailedError: If the current url indicates an authentication failure that prevents an access token from being retrieved.
6782
:raises UnsupportedByProviderError: If the provider only supports implicit flow and has no token endpoint.
@@ -88,6 +103,9 @@ def handle_authentication_result(
88103
token_endpoint=self._base_client.provider_config.token_endpoint,
89104
client_authentication=self._base_client.client_auth,
90105
redirect_uri=redirect_uri.tostr(),
106+
code_verifier=code_verifier,
107+
code_challenge=code_challenge,
108+
code_challenge_method=code_challenge_method,
91109
)
92110

93111
def exchange_code_for_tokens(

src/simple_openid_connect/pkce.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Simple module to generate PKCE code verifier and code challenge.
3+
4+
Original code from @MartinThoma in this file:
5+
https://github.com/RomeoDespres/pkce/blob/master/pkce/__init__.py
6+
7+
8+
Examples
9+
--------
10+
>>> from simple_openid_connect import pkce
11+
>>> code_verifier, code_challenge = pkce.generate_pkce_pair()
12+
13+
>>> from simple_openid_connect import pkce
14+
>>> code_verifier = pkce.generate_code_verifier(length=128)
15+
>>> code_challenge = pkce.get_code_challenge(code_verifier)
16+
"""
17+
18+
import base64
19+
import hashlib
20+
import secrets
21+
from typing import Tuple
22+
23+
24+
def generate_code_verifier(length: int = 128) -> str:
25+
"""Return a random PKCE-compliant code verifier.
26+
27+
Parameters
28+
----------
29+
length : int
30+
Code verifier length. Must verify `43 <= length <= 128`.
31+
32+
Returns
33+
-------
34+
code_verifier : str
35+
Code verifier.
36+
37+
Raises
38+
------
39+
ValueError
40+
When `43 <= length <= 128` is not verified.
41+
"""
42+
if not 43 <= length <= 128:
43+
msg = "Parameter `length` must verify `43 <= length <= 128`."
44+
raise ValueError(msg)
45+
code_verifier = secrets.token_urlsafe(96)[:length]
46+
return code_verifier
47+
48+
49+
def generate_pkce_pair(code_verifier_length: int = 128) -> Tuple[str, str]:
50+
"""Return random PKCE-compliant code verifier and code challenge.
51+
52+
Parameters
53+
----------
54+
code_verifier_length : int
55+
Code verifier length. Must verify
56+
`43 <= code_verifier_length <= 128`.
57+
58+
Returns
59+
-------
60+
code_verifier : str
61+
code_challenge : str
62+
63+
Raises
64+
------
65+
ValueError
66+
When `43 <= code_verifier_length <= 128` is not verified.
67+
"""
68+
if not 43 <= code_verifier_length <= 128:
69+
msg = "Parameter `code_verifier_length` must verify "
70+
msg += "`43 <= code_verifier_length <= 128`."
71+
raise ValueError(msg)
72+
code_verifier = generate_code_verifier(code_verifier_length)
73+
code_challenge = get_code_challenge(code_verifier)
74+
return code_verifier, code_challenge
75+
76+
77+
def get_code_challenge(code_verifier: str) -> str:
78+
"""Return the PKCE-compliant code challenge for a given verifier.
79+
80+
Parameters
81+
----------
82+
code_verifier : str
83+
Code verifier. Must verify `43 <= len(code_verifier) <= 128`.
84+
85+
Returns
86+
-------
87+
code_challenge : str
88+
Code challenge that corresponds to the input code verifier.
89+
90+
Raises
91+
------
92+
ValueError
93+
When `43 <= len(code_verifier) <= 128` is not verified.
94+
"""
95+
if not 43 <= len(code_verifier) <= 128:
96+
msg = "Parameter `code_verifier` must verify "
97+
msg += "`43 <= len(code_verifier) <= 128`."
98+
raise ValueError(msg)
99+
hashed = hashlib.sha256(code_verifier.encode("ascii")).digest()
100+
encoded = base64.urlsafe_b64encode(hashed)
101+
code_challenge = encoded.decode("ascii")[:-1]
102+
return code_challenge

0 commit comments

Comments
 (0)