Skip to content

Commit 9c3302a

Browse files
authored
Add Support for PKCE (#187)
Add Support for PKCE * Add PKCE support in oauth flow * Change assertion to badInputExceptions * Add command line oauth example
1 parent 4f39e3d commit 9c3302a

File tree

5 files changed

+216
-53
lines changed

5 files changed

+216
-53
lines changed

dropbox/dropbox.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -168,23 +168,22 @@ def __init__(self,
168168
:param datetime oauth2_access_token_expiration: Expiration for oauth2_access_token
169169
:param str app_key: application key of requesting application; used for token refresh
170170
:param str app_secret: application secret of requesting application; used for token refresh
171+
Not required if PKCE was used to authorize the token
171172
:param list scope: list of scopes to request on refresh. If left blank,
172173
refresh will request all available scopes for application
173174
"""
174175

175-
assert oauth2_access_token or oauth2_refresh_token, \
176-
'OAuth2 access token or refresh token must be set'
176+
if not (oauth2_access_token or oauth2_refresh_token):
177+
raise BadInputException('OAuth2 access token or refresh token must be set')
177178

178-
assert headers is None or isinstance(headers, dict), \
179-
'Expected dict, got %r' % headers
179+
if headers is not None and not isinstance(headers, dict):
180+
raise BadInputException('Expected dict, got {}'.format(headers))
180181

181-
if oauth2_refresh_token:
182-
assert app_key and app_secret, \
183-
"app_key and app_secret are required to refresh tokens"
182+
if oauth2_refresh_token and not app_key:
183+
raise BadInputException("app_key is required to refresh tokens")
184184

185-
if scope is not None:
186-
assert len(scope) > 0 and isinstance(scope, list), \
187-
"Scope list must be of type list"
185+
if scope is not None and (len(scope) == 0 or not isinstance(scope, list)):
186+
raise BadInputException("Scope list must be of type list")
188187

189188
self._oauth2_access_token = oauth2_access_token
190189
self._oauth2_refresh_token = oauth2_refresh_token
@@ -197,8 +196,9 @@ def __init__(self,
197196
self._max_retries_on_error = max_retries_on_error
198197
self._max_retries_on_rate_limit = max_retries_on_rate_limit
199198
if session:
200-
assert isinstance(session, requests.sessions.Session), \
201-
'Expected requests.sessions.Session, got %r' % session
199+
if not isinstance(session, requests.sessions.Session):
200+
raise BadInputException('Expected requests.sessions.Session, got {}'
201+
.format(session))
202202
self._session = session
203203
else:
204204
self._session = create_session()
@@ -346,7 +346,7 @@ def check_and_refresh_access_token(self):
346346
Checks if access token needs to be refreshed and refreshes if possible
347347
:return:
348348
"""
349-
can_refresh = self._oauth2_refresh_token and self._app_key and self._app_secret
349+
can_refresh = self._oauth2_refresh_token and self._app_key
350350
needs_refresh = self._oauth2_access_token_expiration and \
351351
(datetime.utcnow() + timedelta(seconds=TOKEN_EXPIRATION_BUFFER)) >= \
352352
self._oauth2_access_token_expiration
@@ -363,22 +363,22 @@ def refresh_access_token(self, host=API_HOST, scope=None):
363363
:return:
364364
"""
365365

366-
if scope is not None:
367-
assert len(scope) > 0 and isinstance(scope, list), \
368-
"Scope list must be of type list"
366+
if scope is not None and (len(scope) == 0 or not isinstance(scope, list)):
367+
raise BadInputException("Scope list must be of type list")
369368

370-
if not (self._oauth2_refresh_token and self._app_key and self._app_secret):
369+
if not (self._oauth2_refresh_token and self._app_key):
371370
self._logger.warning('Unable to refresh access token without \
372-
refresh token, app key, and app secret')
371+
refresh token and app key')
373372
return
374373

375374
self._logger.info('Refreshing access token.')
376375
url = "https://{}/oauth2/token".format(host)
377376
body = {'grant_type': 'refresh_token',
378377
'refresh_token': self._oauth2_refresh_token,
379378
'client_id': self._app_key,
380-
'client_secret': self._app_secret,
381379
}
380+
if self._app_secret:
381+
body['client_secret'] = self._app_secret
382382
if scope:
383383
scope = " ".join(scope)
384384
body['scope'] = scope
@@ -719,3 +719,12 @@ def _get_dropbox_client_with_select_header(self, select_header_name, team_member
719719
session=self._session,
720720
headers=new_headers,
721721
)
722+
723+
class BadInputException(Exception):
724+
"""
725+
Thrown if incorrect types/values are used
726+
727+
This should only ever be thrown during testing, app should have validation of input prior to
728+
reaching this point
729+
"""
730+
pass

dropbox/oauth.py

Lines changed: 97 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import hashlib
2+
13
__all__ = [
24
'BadRequestException',
35
'BadStateException',
@@ -14,6 +16,7 @@
1416
import os
1517
import six
1618
import urllib
19+
import re
1720
from datetime import datetime, timedelta
1821

1922
from .session import (
@@ -31,6 +34,7 @@
3134

3235
TOKEN_ACCESS_TYPES = ['offline', 'online', 'legacy']
3336
INCLUDE_GRANTED_SCOPES_TYPES = ['user', 'team']
37+
PKCE_VERIFIER_LENGTH = 128
3438

3539
class 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

121130
class 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

337370
class 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+
591643
def _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
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env python3
2+
3+
import dropbox
4+
from dropbox import DropboxOAuth2FlowNoRedirect
5+
6+
'''
7+
This example uses PKCE, a currently beta feature.
8+
If you are interested in using this, please contact
9+
Dropbox support
10+
'''
11+
APP_KEY = ""
12+
13+
auth_flow = DropboxOAuth2FlowNoRedirect(APP_KEY, pkce_method='S256', token_access_type='offline')
14+
15+
authorize_url = auth_flow.start()
16+
print("1. Go to: " + authorize_url)
17+
print("2. Click \"Allow\" (you might have to log in first).")
18+
print("3. Copy the authorization code.")
19+
auth_code = input("Enter the authorization code here: ").strip()
20+
21+
try:
22+
oauth_result = auth_flow.finish(auth_code)
23+
print(oauth_result)
24+
except Exception as e:
25+
print('Error: %s' % (e,))
26+
exit(1)
27+
28+
dbx = dropbox.Dropbox(oauth2_refresh_token=oauth_result.refresh_token, app_key=APP_KEY)
29+
dbx.users_get_current_account()

example/commandline-oauth.py renamed to example/oauth/commandline-oauth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
print(oauth_result)
2020
except Exception as e:
2121
print('Error: %s' % (e,))
22+
exit(1)
2223

2324
dbx = dropbox.Dropbox(oauth2_access_token=oauth_result.access_token,
2425
app_key=APP_KEY, app_secret=APP_SECRET)

0 commit comments

Comments
 (0)