Skip to content

Commit 82a0efa

Browse files
authored
Add Support for Short-Lived Tokens (#183)
Add Support for Short-Lived Tokens Add token_access_type to oauth flow - Legacy - default - the current long lived token flow - Online - requests only a short-lived access token - Offline - requests both a short-lived access token and a refresh token Add refresh check to each API call Add refresh check on client creation NOTE: Non-Legacy token_access_types currently require beta access, please reach out to Dropbox support if this is something you are interested in
1 parent 906d102 commit 82a0efa

File tree

6 files changed

+404
-26
lines changed

6 files changed

+404
-26
lines changed

dropbox/dropbox.py

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import requests
1818
import six
1919

20+
from datetime import datetime, timedelta
2021
from . import files, stone_serializers
2122
from .auth import (
2223
AuthError_validator,
@@ -50,6 +51,7 @@
5051

5152
PATH_ROOT_HEADER = 'Dropbox-API-Path-Root'
5253
HTTP_STATUS_INVALID_PATH_ROOT = 422
54+
TOKEN_EXPIRATION_BUFFER = 300
5355

5456
class RouteResult(object):
5557
"""The successful result of a call to a route."""
@@ -129,17 +131,22 @@ class _DropboxTransport(object):
129131
_DEFAULT_TIMEOUT = 100
130132

131133
def __init__(self,
132-
oauth2_access_token,
134+
oauth2_access_token=None,
133135
max_retries_on_error=4,
134136
max_retries_on_rate_limit=None,
135137
user_agent=None,
136138
session=None,
137139
headers=None,
138-
timeout=_DEFAULT_TIMEOUT):
140+
timeout=_DEFAULT_TIMEOUT,
141+
oauth2_refresh_token=None,
142+
oauth2_access_token_expiration=None,
143+
app_key=None,
144+
app_secret=None):
139145
"""
140146
:param str oauth2_access_token: OAuth2 access token for making client
141147
requests.
142-
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
143150
:param int max_retries_on_error: On 5xx errors, the number of times to
144151
retry.
145152
:param Optional[int] max_retries_on_rate_limit: On 429 errors, the
@@ -159,11 +166,23 @@ def __init__(self,
159166
connection. If `None`, client will wait forever. Defaults
160167
to 30 seconds.
161168
"""
162-
assert len(oauth2_access_token) > 0, \
163-
'OAuth2 access token cannot be empty.'
169+
170+
assert oauth2_access_token or oauth2_refresh_token, \
171+
'OAuth2 access token or refresh token must be set'
172+
164173
assert headers is None or isinstance(headers, dict), \
165174
'Expected dict, got %r' % headers
175+
176+
if oauth2_refresh_token:
177+
assert app_key and app_secret, \
178+
"app_key and app_secret are required to refresh tokens"
179+
166180
self._oauth2_access_token = oauth2_access_token
181+
self._oauth2_refresh_token = oauth2_refresh_token
182+
self._oauth2_access_token_expiration = oauth2_access_token_expiration
183+
184+
self._app_key = app_key
185+
self._app_secret = app_secret
167186

168187
self._max_retries_on_error = max_retries_on_error
169188
self._max_retries_on_rate_limit = max_retries_on_rate_limit
@@ -199,14 +218,18 @@ def clone(
199218
user_agent=None,
200219
session=None,
201220
headers=None,
202-
timeout=None):
221+
timeout=None,
222+
oauth2_refresh_token=None,
223+
oauth2_access_token_expiration=None,
224+
app_key=None,
225+
app_secret=None):
203226
"""
204227
Creates a new copy of the Dropbox client with the same defaults unless modified by
205228
arguments to clone()
206229
207230
See constructor for original parameter descriptions.
208231
209-
:return: New instance of Dropbox clent
232+
:return: New instance of Dropbox client
210233
:rtype: Dropbox
211234
"""
212235

@@ -217,7 +240,11 @@ def clone(
217240
user_agent or self._user_agent,
218241
session or self._session,
219242
headers or self._headers,
220-
timeout or self._timeout
243+
timeout or self._timeout,
244+
oauth2_refresh_token or self._oauth2_refresh_token,
245+
oauth2_access_token_expiration or self._oauth2_access_token_expiration,
246+
app_key or self._app_key,
247+
app_secret or self._app_secret,
221248
)
222249

223250
def request(self,
@@ -247,6 +274,9 @@ def request(self,
247274
Dropbox object. Defaults to `None`.
248275
:return: The route's result.
249276
"""
277+
278+
self.check_and_refresh_access_token()
279+
250280
host = route.attrs['host'] or 'api'
251281
route_name = namespace + '/' + route.name
252282
if route.version > 1:
@@ -299,6 +329,53 @@ def request(self,
299329
else:
300330
return deserialized_result
301331

332+
def check_and_refresh_access_token(self):
333+
"""
334+
Checks if access token needs to be refreshed and refreshes if possible
335+
336+
:return:
337+
"""
338+
can_refresh = self._oauth2_refresh_token and self._app_key and self._app_secret
339+
needs_refresh = self._oauth2_access_token_expiration and \
340+
(datetime.utcnow() + timedelta(seconds=TOKEN_EXPIRATION_BUFFER)) >= \
341+
self._oauth2_access_token_expiration
342+
needs_token = not self._oauth2_access_token
343+
if (needs_refresh or needs_token) and can_refresh:
344+
self.refresh_access_token()
345+
346+
def refresh_access_token(self, host=API_HOST):
347+
"""
348+
Refreshes an access token via refresh token if available
349+
350+
:return:
351+
"""
352+
353+
if not (self._oauth2_refresh_token and self._app_key and self._app_secret):
354+
self._logger.warning('Unable to refresh access token without \
355+
refresh token, app key, and app secret')
356+
return
357+
358+
self._logger.info('Refreshing access token.')
359+
url = "https://{}/oauth2/token".format(host)
360+
body = {'grant_type': 'refresh_token',
361+
'refresh_token': self._oauth2_refresh_token,
362+
'client_id': self._app_key,
363+
'client_secret': self._app_secret,
364+
}
365+
366+
res = self._session.post(url, data=body)
367+
if res.status_code == 400 and res.json()['error'] == 'invalid_grant':
368+
request_id = res.headers.get('x-dropbox-request-id')
369+
err = stone_serializers.json_compat_obj_decode(
370+
AuthError_validator, 'invalid_access_token')
371+
raise AuthError(request_id, err)
372+
res.raise_for_status()
373+
374+
token_content = res.json()
375+
self._oauth2_access_token = token_content["access_token"]
376+
self._oauth2_access_token_expiration = datetime.utcnow() + \
377+
timedelta(seconds=int(token_content["expires_in"]))
378+
302379
def request_json_object(self,
303380
host,
304381
route_name,
@@ -354,6 +431,7 @@ def request_json_string_with_retry(self,
354431
"""
355432
attempt = 0
356433
rate_limit_errors = 0
434+
has_refreshed = False
357435
while True:
358436
self._logger.info('Request to %s', route_name)
359437
try:
@@ -363,6 +441,18 @@ def request_json_string_with_retry(self,
363441
request_json_arg,
364442
request_binary,
365443
timeout=timeout)
444+
except AuthError as e:
445+
if e.error and e.error.is_expired_access_token():
446+
if has_refreshed:
447+
raise
448+
else:
449+
self._logger.info(
450+
'ExpiredCredentials status_code=%s: Refreshing and Retrying',
451+
e.status_code)
452+
self.refresh_access_token()
453+
has_refreshed = True
454+
else:
455+
raise
366456
except InternalServerError as e:
367457
attempt += 1
368458
if attempt <= self._max_retries_on_error:
@@ -600,6 +690,8 @@ def _get_dropbox_client_with_select_header(self, select_header_name, team_member
600690
new_headers[select_header_name] = team_member_id
601691
return Dropbox(
602692
self._oauth2_access_token,
693+
oauth2_refresh_token=self._oauth2_refresh_token,
694+
oauth2_access_token_expiration=self._oauth2_access_token_expiration,
603695
max_retries_on_error=self._max_retries_on_error,
604696
max_retries_on_rate_limit=self._max_retries_on_rate_limit,
605697
timeout=self._timeout,

0 commit comments

Comments
 (0)