1515import functools
1616import random
1717import string
18+ import hashlib
1819
1920import requests
2021
@@ -258,6 +259,21 @@ def _stringify(self, sequence):
258259 return sequence # as-is
259260
260261
262+ def _generate_pkce_code_verifier (length = 43 ):
263+ assert 43 <= length <= 128
264+ verifier = "" .join ( # https://tools.ietf.org/html/rfc7636#section-4.1
265+ random .sample (string .ascii_letters + string .digits + "-._~" , length ))
266+ code_challenge = (
267+ # https://tools.ietf.org/html/rfc7636#section-4.2
268+ base64 .urlsafe_b64encode (hashlib .sha256 (verifier .encode ("ascii" )).digest ())
269+ .rstrip (b"=" )) # Required by https://tools.ietf.org/html/rfc7636#section-3
270+ return {
271+ "code_verifier" : verifier ,
272+ "transformation" : "S256" , # In Python, sha256 is always available
273+ "code_challenge" : code_challenge ,
274+ }
275+
276+
261277class Client (BaseClient ): # We choose to implement all 4 grants in 1 class
262278 """This is the main API for oauth2 client.
263279
@@ -401,6 +417,8 @@ def initiate_auth_code_flow(
401417 you can use :func:`~obtain_token_by_auth_code_flow()`
402418 to complete the authentication/authorization.
403419
420+ This method also provides PKCE protection automatically.
421+
404422 :param list scope:
405423 It is a list of case-sensitive strings.
406424 Some ID provider can accept empty string to represent default scope.
@@ -440,14 +458,19 @@ def initiate_auth_code_flow(
440458 # Implicit grant would cause auth response coming back in #fragment,
441459 # but fragment won't reach a web service.
442460 raise ValueError ('response_type="token ..." is not allowed' )
461+ pkce = _generate_pkce_code_verifier ()
443462 flow = { # These data are required by obtain_token_by_auth_code_flow()
444463 "state" : state or "" .join (random .sample (string .ascii_letters , 16 )),
445464 "redirect_uri" : redirect_uri ,
446465 "scope" : scope ,
447466 }
448467 auth_uri = self ._build_auth_request_uri (
449- response_type , ** dict (flow , ** kwargs ))
468+ response_type ,
469+ code_challenge = pkce ["code_challenge" ],
470+ code_challenge_method = pkce ["transformation" ],
471+ ** dict (flow , ** kwargs ))
450472 flow ["auth_uri" ] = auth_uri
473+ flow ["code_verifier" ] = pkce ["code_verifier" ]
451474 return flow
452475
453476 def obtain_token_by_auth_code_flow (
@@ -459,6 +482,8 @@ def obtain_token_by_auth_code_flow(
459482 """With the auth_response being redirected back,
460483 validate it against auth_code_flow, and then obtain tokens.
461484
485+ Internally, it implements PKCE to mitigate the auth code interception attack.
486+
462487 :param dict auth_code_flow:
463488 The same dict returned by :func:`~initiate_auth_code_flow()`.
464489 :param dict auth_response:
@@ -513,6 +538,10 @@ def authorize(): # A controller in a web app
513538 # It is both unnecessary and harmless, per RFC 6749.
514539 # We use the same scope already used in auth request uri,
515540 # thus token cache can know what scope the tokens are for.
541+ data = dict ( # Extract and update the data
542+ kwargs .pop ("data" , {}),
543+ code_verifier = auth_code_flow ["code_verifier" ],
544+ ),
516545 ** kwargs )
517546 if auth_response .get ("error" ): # It means the first leg encountered error
518547 # Here we do NOT return original auth_response as-is, to prevent a
0 commit comments