Skip to content

Commit 129035e

Browse files
authored
Add Support for Scopes (#185)
Add Support for Scopes - Add option for requesting specific scopes and including already granted scopes in pauth flow - Add support for default scope list in Dropbox client - Add support for requesting specific scopes on token refresh - Update oauth command line documentation to work with python3 - Update tox.ini to run pytest instead of outdated python setup.py test - Add venv to .gitignore
1 parent 82a0efa commit 129035e

File tree

8 files changed

+221
-35
lines changed

8 files changed

+221
-35
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ __pycache__/
1212
*.pyc
1313
*.pyo
1414
*~
15-
.venv
15+
.venv
16+
venv/
17+
venv3/

dropbox/dropbox.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,11 @@ def __init__(self,
141141
oauth2_refresh_token=None,
142142
oauth2_access_token_expiration=None,
143143
app_key=None,
144-
app_secret=None):
144+
app_secret=None,
145+
scope=None,):
145146
"""
146147
:param str oauth2_access_token: OAuth2 access token for making client
147148
requests.
148-
:param str oauth2_refresh_token: OAuth2 refresh token for refreshing access token
149-
:param datetime oauth2_access_token_expiration: Expiration for oauth2_access_token
150149
:param int max_retries_on_error: On 5xx errors, the number of times to
151150
retry.
152151
:param Optional[int] max_retries_on_rate_limit: On 429 errors, the
@@ -165,6 +164,12 @@ def __init__(self,
165164
server. After the timeout the client will give up on
166165
connection. If `None`, client will wait forever. Defaults
167166
to 30 seconds.
167+
:param str oauth2_refresh_token: OAuth2 refresh token for refreshing access token
168+
:param datetime oauth2_access_token_expiration: Expiration for oauth2_access_token
169+
:param str app_key: application key of requesting application; used for token refresh
170+
:param str app_secret: application secret of requesting application; used for token refresh
171+
:param list scope: list of scopes to request on refresh. If left blank,
172+
refresh will request all available scopes for application
168173
"""
169174

170175
assert oauth2_access_token or oauth2_refresh_token, \
@@ -177,12 +182,17 @@ def __init__(self,
177182
assert app_key and app_secret, \
178183
"app_key and app_secret are required to refresh tokens"
179184

185+
if scope is not None:
186+
assert len(scope) > 0 and isinstance(scope, list), \
187+
"Scope list must be of type list"
188+
180189
self._oauth2_access_token = oauth2_access_token
181190
self._oauth2_refresh_token = oauth2_refresh_token
182191
self._oauth2_access_token_expiration = oauth2_access_token_expiration
183192

184193
self._app_key = app_key
185194
self._app_secret = app_secret
195+
self._scope = scope
186196

187197
self._max_retries_on_error = max_retries_on_error
188198
self._max_retries_on_rate_limit = max_retries_on_rate_limit
@@ -222,7 +232,8 @@ def clone(
222232
oauth2_refresh_token=None,
223233
oauth2_access_token_expiration=None,
224234
app_key=None,
225-
app_secret=None):
235+
app_secret=None,
236+
scope=None):
226237
"""
227238
Creates a new copy of the Dropbox client with the same defaults unless modified by
228239
arguments to clone()
@@ -245,6 +256,7 @@ def clone(
245256
oauth2_access_token_expiration or self._oauth2_access_token_expiration,
246257
app_key or self._app_key,
247258
app_secret or self._app_secret,
259+
scope or self._scope
248260
)
249261

250262
def request(self,
@@ -332,7 +344,6 @@ def request(self,
332344
def check_and_refresh_access_token(self):
333345
"""
334346
Checks if access token needs to be refreshed and refreshes if possible
335-
336347
:return:
337348
"""
338349
can_refresh = self._oauth2_refresh_token and self._app_key and self._app_secret
@@ -341,15 +352,21 @@ def check_and_refresh_access_token(self):
341352
self._oauth2_access_token_expiration
342353
needs_token = not self._oauth2_access_token
343354
if (needs_refresh or needs_token) and can_refresh:
344-
self.refresh_access_token()
355+
self.refresh_access_token(scope=self._scope)
345356

346-
def refresh_access_token(self, host=API_HOST):
357+
def refresh_access_token(self, host=API_HOST, scope=None):
347358
"""
348359
Refreshes an access token via refresh token if available
349360
361+
:param host: host to hit token endpoint with
362+
:param scope: list of permission scopes for access token
350363
:return:
351364
"""
352365

366+
if scope is not None:
367+
assert len(scope) > 0 and isinstance(scope, list), \
368+
"Scope list must be of type list"
369+
353370
if not (self._oauth2_refresh_token and self._app_key and self._app_secret):
354371
self._logger.warning('Unable to refresh access token without \
355372
refresh token, app key, and app secret')
@@ -362,6 +379,9 @@ def refresh_access_token(self, host=API_HOST):
362379
'client_id': self._app_key,
363380
'client_secret': self._app_secret,
364381
}
382+
if scope:
383+
scope = " ".join(scope)
384+
body['scope'] = scope
365385

366386
res = self._session.post(url, data=body)
367387
if res.status_code == 400 and res.json()['error'] == 'invalid_grant':

dropbox/oauth.py

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
url_encode = urllib.urlencode # pylint: disable=no-member,useless-suppression
3131

3232
TOKEN_ACCESS_TYPES = ['offline', 'online', 'legacy']
33+
INCLUDE_GRANTED_SCOPES_TYPES = ['user', 'team']
3334

3435
class OAuth2FlowNoRedirectResult(object):
3536
"""
@@ -39,17 +40,22 @@ class OAuth2FlowNoRedirectResult(object):
3940
in using them, please contact Dropbox support
4041
"""
4142

42-
def __init__(self, access_token, account_id, user_id, refresh_token, expiration):
43+
def __init__(self, access_token, account_id, user_id, refresh_token, expiration, scope):
4344
"""
4445
Args:
4546
access_token (str): Token to be used to authenticate later
4647
requests.
48+
refresh_token (str): Token to be used to acquire new access token
49+
when existing one expires
50+
expiration (int, datetime): Either the number of seconds from now that the token expires
51+
in or the datetime at which the token expires
4752
account_id (str): The Dropbox user's account ID.
4853
user_id (str): Deprecated (use account_id instead).
4954
refresh_token (str): Token to be used to acquire new access token
5055
when existing one expires
5156
expiration (int, datetime): Either the number of seconds from now that the token expires
5257
in or the datetime at which the token expires
58+
scope (list): list of scopes to request in base oauth flow.
5359
"""
5460
self.access_token = access_token
5561
if not expiration:
@@ -61,14 +67,16 @@ def __init__(self, access_token, account_id, user_id, refresh_token, expiration)
6167
self.refresh_token = refresh_token
6268
self.account_id = account_id
6369
self.user_id = user_id
70+
self.scope = scope
6471

6572
def __repr__(self):
66-
return 'OAuth2FlowNoRedirectResult(%s, %s, %s, %s, %s)' % (
73+
return 'OAuth2FlowNoRedirectResult(%s, %s, %s, %s, %s, %s)' % (
6774
self.access_token,
6875
self.account_id,
6976
self.user_id,
7077
self.refresh_token,
7178
self.expires_at,
79+
self.scope,
7280
)
7381

7482

@@ -77,7 +85,8 @@ class OAuth2FlowResult(OAuth2FlowNoRedirectResult):
7785
Authorization information for an OAuth2Flow with redirect.
7886
"""
7987

80-
def __init__(self, access_token, account_id, user_id, url_state, refresh_token, expires_in):
88+
def __init__(self, access_token, account_id, user_id, url_state, refresh_token,
89+
expires_in, scope):
8190
"""
8291
Same as OAuth2FlowNoRedirectResult but with url_state.
8392
@@ -86,20 +95,23 @@ def __init__(self, access_token, account_id, user_id, url_state, refresh_token,
8695
:meth:`DropboxOAuth2Flow.start`.
8796
"""
8897
super(OAuth2FlowResult, self).__init__(
89-
access_token, account_id, user_id, refresh_token, expires_in)
98+
access_token, account_id, user_id, refresh_token, expires_in, scope)
9099
self.url_state = url_state
91100

92101
@classmethod
93102
def from_no_redirect_result(cls, result, url_state):
94103
assert isinstance(result, OAuth2FlowNoRedirectResult)
95104
return cls(result.access_token, result.account_id, result.user_id,
96-
url_state, result.refresh_token, result.expires_at)
105+
url_state, result.refresh_token, result.expires_at, result.scope)
97106

98107
def __repr__(self):
99-
return 'OAuth2FlowResult(%s, %s, %s, %s, %s, %s)' % (
108+
return 'OAuth2FlowResult(%s, %s, %s, %s, %s, %s, %s, %s, %s)' % (
100109
self.access_token,
110+
self.refresh_token,
111+
self.expires_at,
101112
self.account_id,
102113
self.user_id,
114+
self.scope,
103115
self.url_state,
104116
self.refresh_token,
105117
self.expires_at,
@@ -108,14 +120,22 @@ def __repr__(self):
108120

109121
class DropboxOAuth2FlowBase(object):
110122

111-
def __init__(self, consumer_key, consumer_secret, locale=None, token_access_type='legacy'):
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"
128+
112129
self.consumer_key = consumer_key
113130
self.consumer_secret = consumer_secret
114131
self.locale = locale
115132
self.token_access_type = token_access_type
116133
self.requests_session = pinned_session()
134+
self.scope = scope
135+
self.include_granted_scopes = include_granted_scopes
117136

118-
def _get_authorize_url(self, redirect_uri, state, token_access_type):
137+
def _get_authorize_url(self, redirect_uri, state, token_access_type, scope=None,
138+
include_granted_scopes=None):
119139
params = dict(response_type='code',
120140
client_id=self.consumer_key)
121141
if redirect_uri is not None:
@@ -127,6 +147,12 @@ def _get_authorize_url(self, redirect_uri, state, token_access_type):
127147
if token_access_type != 'legacy':
128148
params['token_access_type'] = token_access_type
129149

150+
if scope is not None:
151+
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)
155+
130156
return self.build_url('/oauth2/authorize', params, WEB_HOST)
131157

132158
def _finish(self, code, redirect_uri):
@@ -163,6 +189,11 @@ def _finish(self, code, redirect_uri):
163189
else:
164190
expires_in = None
165191

192+
if 'scope' in d:
193+
scope = d['scope']
194+
else:
195+
scope = None
196+
166197
uid = d['uid']
167198

168199
return OAuth2FlowNoRedirectResult(
@@ -171,7 +202,7 @@ def _finish(self, code, redirect_uri):
171202
uid,
172203
refresh_token,
173204
expires_in,
174-
)
205+
scope)
175206

176207
def build_path(self, target, params=None):
177208
"""Build the path component for an API URL.
@@ -228,21 +259,23 @@ class DropboxOAuth2FlowNoRedirect(DropboxOAuth2FlowBase):
228259
auth_flow = DropboxOAuth2FlowNoRedirect(APP_KEY, APP_SECRET)
229260
230261
authorize_url = auth_flow.start()
231-
print "1. Go to: " + authorize_url
232-
print "2. Click \\"Allow\\" (you might have to log in first)."
233-
print "3. Copy the authorization code."
262+
print("1. Go to: " + authorize_url)
263+
print("2. Click \\"Allow\\" (you might have to log in first).")
264+
print("3. Copy the authorization code.")
234265
auth_code = raw_input("Enter the authorization code here: ").strip()
235266
236267
try:
237268
oauth_result = auth_flow.finish(auth_code)
238-
except Exception, e:
269+
except Exception as e:
239270
print('Error: %s' % (e,))
240271
return
241272
242273
dbx = Dropbox(oauth_result.access_token)
243274
"""
244275

245-
def __init__(self, consumer_key, consumer_secret, locale=None, token_access_type='legacy'): # noqa: E501; pylint: disable=useless-super-delegation
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
246279
"""
247280
Construct an instance.
248281
@@ -258,12 +291,21 @@ def __init__(self, consumer_key, consumer_secret, locale=None, token_access_type
258291
legacy - creates one long-lived token with no expiration
259292
online - create one short-lived token with an expiration
260293
offline - create one short-lived token with an expiration with a refresh token
294+
:param list scope: list of scopes to request in base oauth flow. If left blank,
295+
will default to all scopes for app
296+
:param str include_granted_scopes: which scopes to include from previous grants
297+
From the following enum:
298+
user - include user scopes in the grant
299+
team - include team scopes in the grant
300+
Note: if this user has never linked the app, include_granted_scopes must be None
261301
"""
262302
super(DropboxOAuth2FlowNoRedirect, self).__init__(
263303
consumer_key,
264304
consumer_secret,
265305
locale,
266306
token_access_type,
307+
scope,
308+
include_granted_scopes,
267309
)
268310

269311
def start(self):
@@ -275,7 +317,8 @@ def start(self):
275317
access the user's Dropbox account. Tell the user to visit this URL
276318
and approve your app.
277319
"""
278-
return self._get_authorize_url(None, None, self.token_access_type)
320+
return self._get_authorize_url(None, None, self.token_access_type, self.scope,
321+
self.include_granted_scopes)
279322

280323
def finish(self, code):
281324
"""
@@ -337,7 +380,8 @@ def dropbox_auth_finish(web_app_session, request):
337380
"""
338381

339382
def __init__(self, consumer_key, consumer_secret, redirect_uri, session,
340-
csrf_token_session_key, locale=None, token_access_type='legacy'):
383+
csrf_token_session_key, locale=None, token_access_type='legacy',
384+
scope=None, include_granted_scopes=None):
341385
"""
342386
Construct an instance.
343387
@@ -361,9 +405,17 @@ def __init__(self, consumer_key, consumer_secret, redirect_uri, session,
361405
legacy - creates one long-lived token with no expiration
362406
online - create one short-lived token with an expiration
363407
offline - create one short-lived token with an expiration with a refresh token
408+
:param list scope: list of scopes to request in base oauth flow. If left blank,
409+
will default to all scopes for app
410+
:param str include_granted_scopes: which scopes to include from previous grants
411+
From the following enum:
412+
user - include user scopes in the grant
413+
team - include team scopes in the grant
414+
Note: if this user has never linked the app, include_granted_scopes must be None
364415
"""
365-
super(DropboxOAuth2Flow, self).__init__(consumer_key, consumer_secret,
366-
locale, token_access_type)
416+
super(DropboxOAuth2Flow, self).__init__(consumer_key, consumer_secret, locale,
417+
token_access_type, scope,
418+
include_granted_scopes)
367419
self.redirect_uri = redirect_uri
368420
self.session = session
369421
self.csrf_token_session_key = csrf_token_session_key
@@ -397,7 +449,8 @@ def start(self, url_state=None):
397449
state += "|" + url_state
398450
self.session[self.csrf_token_session_key] = csrf_token
399451

400-
return self._get_authorize_url(self.redirect_uri, state, self.token_access_type)
452+
return self._get_authorize_url(self.redirect_uri, state, self.token_access_type,
453+
self.scope, self.include_granted_scopes)
401454

402455
def finish(self, query_params):
403456
"""

example/commandline-oauth.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env python3
2+
3+
import dropbox
4+
from dropbox import DropboxOAuth2FlowNoRedirect
5+
6+
APP_KEY = ""
7+
APP_SECRET = ""
8+
9+
auth_flow = DropboxOAuth2FlowNoRedirect(APP_KEY, APP_SECRET)
10+
11+
authorize_url = auth_flow.start()
12+
print("1. Go to: " + authorize_url)
13+
print("2. Click \"Allow\" (you might have to log in first).")
14+
print("3. Copy the authorization code.")
15+
auth_code = input("Enter the authorization code here: ").strip()
16+
17+
try:
18+
oauth_result = auth_flow.finish(auth_code)
19+
print(oauth_result)
20+
except Exception as e:
21+
print('Error: %s' % (e,))
22+
23+
dbx = dropbox.Dropbox(oauth2_access_token=oauth_result.access_token,
24+
app_key=APP_KEY, app_secret=APP_SECRET)
25+
dbx.users_get_current_account()

test/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
pytest
12
mock
2-
pytest-mock
3+
pytest-mock

0 commit comments

Comments
 (0)