1+ import hashlib
2+
13__all__ = [
24 'BadRequestException' ,
35 'BadStateException' ,
1416import os
1517import six
1618import urllib
19+ import re
1720from datetime import datetime , timedelta
1821
1922from .session import (
3134
3235TOKEN_ACCESS_TYPES = ['offline' , 'online' , 'legacy' ]
3336INCLUDE_GRANTED_SCOPES_TYPES = ['user' , 'team' ]
37+ PKCE_VERIFIER_LENGTH = 128
3438
3539class OAuth2FlowNoRedirectResult (object ):
3640 """
@@ -95,7 +99,12 @@ def __init__(self, access_token, account_id, user_id, url_state, refresh_token,
9599 :meth:`DropboxOAuth2Flow.start`.
96100 """
97101 super (OAuth2FlowResult , self ).__init__ (
98- access_token , account_id , user_id , refresh_token , expires_in , scope )
102+ access_token = access_token ,
103+ account_id = account_id ,
104+ user_id = user_id ,
105+ refresh_token = refresh_token ,
106+ expires_in = expires_in ,
107+ scope = scope )
99108 self .url_state = url_state
100109
101110 @classmethod
@@ -120,11 +129,17 @@ def __repr__(self):
120129
121130class DropboxOAuth2FlowBase (object ):
122131
123- def __init__ (self , consumer_key , consumer_secret , locale = None , token_access_type = 'legacy' ,
124- scope = None , include_granted_scopes = None ):
125- if scope is not None :
126- assert len (scope ) > 0 and isinstance (scope , list ), \
127- "Scope list must be of type list"
132+ def __init__ (self , consumer_key , consumer_secret = None , locale = None , token_access_type = 'legacy' ,
133+ scope = None , include_granted_scopes = None , use_pkce = False ):
134+ if scope is not None and (len (scope ) == 0 or not isinstance (scope , list )):
135+ raise BadInputException ("Scope list must be of type list" )
136+ if token_access_type is not None and token_access_type not in TOKEN_ACCESS_TYPES :
137+ raise BadInputException ("Token access type must be from the following enum: {}" .format (
138+ TOKEN_ACCESS_TYPES ))
139+ if not (use_pkce or consumer_secret ):
140+ raise BadInputException ("Must pass in either consumer secret or use PKCE" )
141+ if include_granted_scopes and not scope :
142+ raise BadInputException ("Must pass in scope to pass include_granted_scopes" )
128143
129144 self .consumer_key = consumer_key
130145 self .consumer_secret = consumer_secret
@@ -134,8 +149,15 @@ def __init__(self, consumer_key, consumer_secret, locale=None, token_access_type
134149 self .scope = scope
135150 self .include_granted_scopes = include_granted_scopes
136151
152+ if use_pkce :
153+ self .code_verifier = _generate_pkce_code_verifier ()
154+ self .code_challenge = _generate_pkce_code_challenge (self .code_verifier )
155+ else :
156+ self .code_verifier = None
157+ self .code_challenge = None
158+
137159 def _get_authorize_url (self , redirect_uri , state , token_access_type , scope = None ,
138- include_granted_scopes = None ):
160+ include_granted_scopes = None , code_challenge = None ):
139161 params = dict (response_type = 'code' ,
140162 client_id = self .consumer_key )
141163 if redirect_uri is not None :
@@ -146,22 +168,28 @@ def _get_authorize_url(self, redirect_uri, state, token_access_type, scope=None,
146168 assert token_access_type in TOKEN_ACCESS_TYPES
147169 if token_access_type != 'legacy' :
148170 params ['token_access_type' ] = token_access_type
171+ if code_challenge :
172+ params ['code_challenge' ] = code_challenge
173+ params ['code_challenge_method' ] = 'S256'
149174
150175 if scope is not None :
151176 params ['scope' ] = " " .join (scope )
152- if include_granted_scopes is not None :
153- assert include_granted_scopes in INCLUDE_GRANTED_SCOPES_TYPES
154- params ['include_granted_scopes' ] = str ( include_granted_scopes )
177+ if include_granted_scopes is not None :
178+ assert include_granted_scopes in INCLUDE_GRANTED_SCOPES_TYPES
179+ params ['include_granted_scopes' ] = include_granted_scopes
155180
156181 return self .build_url ('/oauth2/authorize' , params , WEB_HOST )
157182
158- def _finish (self , code , redirect_uri ):
183+ def _finish (self , code , redirect_uri , code_verifier ):
159184 url = self .build_url ('/oauth2/token' )
160185 params = {'grant_type' : 'authorization_code' ,
161186 'code' : code ,
162187 'client_id' : self .consumer_key ,
163- 'client_secret' : self .consumer_secret ,
164188 }
189+ if code_verifier :
190+ params ['code_verifier' ] = code_verifier
191+ else :
192+ params ['client_secret' ] = self .consumer_secret
165193 if self .locale is not None :
166194 params ['locale' ] = self .locale
167195 if redirect_uri is not None :
@@ -273,9 +301,8 @@ class DropboxOAuth2FlowNoRedirect(DropboxOAuth2FlowBase):
273301 dbx = Dropbox(oauth_result.access_token)
274302 """
275303
276- def __init__ (self , consumer_key , consumer_secret , locale = None , token_access_type = 'legacy' ,
277- scope = None , include_granted_scopes = None ):
278- # noqa: E501; pylint: disable=useless-super-delegation
304+ def __init__ (self , consumer_key , consumer_secret = None , locale = None , token_access_type = 'legacy' ,
305+ scope = None , include_granted_scopes = None , use_pkce = False ): # noqa: E501;
279306 """
280307 Construct an instance.
281308
@@ -298,14 +325,18 @@ def __init__(self, consumer_key, consumer_secret, locale=None, token_access_type
298325 user - include user scopes in the grant
299326 team - include team scopes in the grant
300327 Note: if this user has never linked the app, include_granted_scopes must be None
328+ :param bool use_pkce: Whether or not to use Sha256 based PKCE. PKCE should be only use on
329+ client apps which doesn't call your server. It is less secure than non-PKCE flow but
330+ can be used if you are unable to safely retrieve your app secret
301331 """
302332 super (DropboxOAuth2FlowNoRedirect , self ).__init__ (
303- consumer_key ,
304- consumer_secret ,
305- locale ,
306- token_access_type ,
307- scope ,
308- include_granted_scopes ,
333+ consumer_key = consumer_key ,
334+ consumer_secret = consumer_secret ,
335+ locale = locale ,
336+ token_access_type = token_access_type ,
337+ scope = scope ,
338+ include_granted_scopes = include_granted_scopes ,
339+ use_pkce = use_pkce ,
309340 )
310341
311342 def start (self ):
@@ -317,8 +348,10 @@ def start(self):
317348 access the user's Dropbox account. Tell the user to visit this URL
318349 and approve your app.
319350 """
320- return self ._get_authorize_url (None , None , self .token_access_type , self .scope ,
321- self .include_granted_scopes )
351+ return self ._get_authorize_url (None , None , self .token_access_type ,
352+ scope = self .scope ,
353+ include_granted_scopes = self .include_granted_scopes ,
354+ code_challenge = self .code_challenge )
322355
323356 def finish (self , code ):
324357 """
@@ -331,7 +364,7 @@ def finish(self, code):
331364 :rtype: OAuth2FlowNoRedirectResult
332365 :raises: The same exceptions as :meth:`DropboxOAuth2Flow.finish()`.
333366 """
334- return self ._finish (code , None )
367+ return self ._finish (code , None , self . code_verifier )
335368
336369
337370class DropboxOAuth2Flow (DropboxOAuth2FlowBase ):
@@ -379,9 +412,10 @@ def dropbox_auth_finish(web_app_session, request):
379412
380413 """
381414
382- def __init__ (self , consumer_key , consumer_secret , redirect_uri , session ,
383- csrf_token_session_key , locale = None , token_access_type = 'legacy' ,
384- scope = None , include_granted_scopes = None ):
415+ def __init__ (self , consumer_key , redirect_uri , session ,
416+ csrf_token_session_key , consumer_secret = None , locale = None ,
417+ token_access_type = 'legacy' , scope = None ,
418+ include_granted_scopes = None , use_pkce = False ):
385419 """
386420 Construct an instance.
387421
@@ -412,10 +446,16 @@ def __init__(self, consumer_key, consumer_secret, redirect_uri, session,
412446 user - include user scopes in the grant
413447 team - include team scopes in the grant
414448 Note: if this user has never linked the app, include_granted_scopes must be None
449+ :param bool use_pkce: Whether or not to use Sha256 based PKCE
415450 """
416- super (DropboxOAuth2Flow , self ).__init__ (consumer_key , consumer_secret , locale ,
417- token_access_type , scope ,
418- include_granted_scopes )
451+ super (DropboxOAuth2Flow , self ).__init__ (
452+ consumer_key = consumer_key ,
453+ consumer_secret = consumer_secret ,
454+ locale = locale ,
455+ token_access_type = token_access_type ,
456+ scope = scope ,
457+ include_granted_scopes = include_granted_scopes ,
458+ use_pkce = use_pkce )
419459 self .redirect_uri = redirect_uri
420460 self .session = session
421461 self .csrf_token_session_key = csrf_token_session_key
@@ -450,7 +490,9 @@ def start(self, url_state=None):
450490 self .session [self .csrf_token_session_key ] = csrf_token
451491
452492 return self ._get_authorize_url (self .redirect_uri , state , self .token_access_type ,
453- self .scope , self .include_granted_scopes )
493+ scope = self .scope ,
494+ include_granted_scopes = self .include_granted_scopes ,
495+ code_challenge = self .code_challenge )
454496
455497 def finish (self , query_params ):
456498 """
@@ -534,7 +576,7 @@ def finish(self, query_params):
534576
535577 # If everything went ok, make the network call to get an access token.
536578
537- no_redirect_result = self ._finish (code , self .redirect_uri )
579+ no_redirect_result = self ._finish (code , self .redirect_uri , self . code_verifier )
538580 return OAuth2FlowResult .from_no_redirect_result (
539581 no_redirect_result , url_state )
540582
@@ -588,6 +630,16 @@ class ProviderException(Exception):
588630 pass
589631
590632
633+ class BadInputException (Exception ):
634+ """
635+ Thrown if incorrect types/values are used
636+
637+ This should only ever be thrown during testing, app should have validation of input prior to
638+ reaching this point
639+ """
640+ pass
641+
642+
591643def _safe_equals (a , b ):
592644 if len (a ) != len (b ):
593645 return False
@@ -616,3 +668,16 @@ def encode(o):
616668
617669 utf8_params = {encode (k ): encode (v ) for k , v in six .iteritems (params )}
618670 return url_encode (utf8_params )
671+
672+ def _generate_pkce_code_verifier ():
673+ code_verifier = base64 .urlsafe_b64encode (os .urandom (PKCE_VERIFIER_LENGTH )).decode ('utf-8' )
674+ code_verifier = re .sub ('[^a-zA-Z0-9]+' , '' , code_verifier )
675+ if len (code_verifier ) > PKCE_VERIFIER_LENGTH :
676+ code_verifier = code_verifier [:128 ]
677+ return code_verifier
678+
679+ def _generate_pkce_code_challenge (code_verifier ):
680+ code_challenge = hashlib .sha256 (code_verifier .encode ('utf-8' )).digest ()
681+ code_challenge = base64 .urlsafe_b64encode (code_challenge ).decode ('utf-8' )
682+ code_challenge = code_challenge .replace ('=' , '' )
683+ return code_challenge
0 commit comments