15
15
import functools
16
16
import random
17
17
import string
18
+ import hashlib
18
19
19
20
import requests
20
21
@@ -258,6 +259,21 @@ def _stringify(self, sequence):
258
259
return sequence # as-is
259
260
260
261
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
+
261
277
class Client (BaseClient ): # We choose to implement all 4 grants in 1 class
262
278
"""This is the main API for oauth2 client.
263
279
@@ -401,6 +417,8 @@ def initiate_auth_code_flow(
401
417
you can use :func:`~obtain_token_by_auth_code_flow()`
402
418
to complete the authentication/authorization.
403
419
420
+ This method also provides PKCE protection automatically.
421
+
404
422
:param list scope:
405
423
It is a list of case-sensitive strings.
406
424
Some ID provider can accept empty string to represent default scope.
@@ -440,14 +458,19 @@ def initiate_auth_code_flow(
440
458
# Implicit grant would cause auth response coming back in #fragment,
441
459
# but fragment won't reach a web service.
442
460
raise ValueError ('response_type="token ..." is not allowed' )
461
+ pkce = _generate_pkce_code_verifier ()
443
462
flow = { # These data are required by obtain_token_by_auth_code_flow()
444
463
"state" : state or "" .join (random .sample (string .ascii_letters , 16 )),
445
464
"redirect_uri" : redirect_uri ,
446
465
"scope" : scope ,
447
466
}
448
467
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 ))
450
472
flow ["auth_uri" ] = auth_uri
473
+ flow ["code_verifier" ] = pkce ["code_verifier" ]
451
474
return flow
452
475
453
476
def obtain_token_by_auth_code_flow (
@@ -459,6 +482,8 @@ def obtain_token_by_auth_code_flow(
459
482
"""With the auth_response being redirected back,
460
483
validate it against auth_code_flow, and then obtain tokens.
461
484
485
+ Internally, it implements PKCE to mitigate the auth code interception attack.
486
+
462
487
:param dict auth_code_flow:
463
488
The same dict returned by :func:`~initiate_auth_code_flow()`.
464
489
:param dict auth_response:
@@ -513,6 +538,10 @@ def authorize(): # A controller in a web app
513
538
# It is both unnecessary and harmless, per RFC 6749.
514
539
# We use the same scope already used in auth request uri,
515
540
# 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
+ ),
516
545
** kwargs )
517
546
if auth_response .get ("error" ): # It means the first leg encountered error
518
547
# Here we do NOT return original auth_response as-is, to prevent a
0 commit comments