1717import requests
1818import six
1919
20+ from datetime import datetime , timedelta
2021from . import files , stone_serializers
2122from .auth import (
2223 AuthError_validator ,
5051
5152PATH_ROOT_HEADER = 'Dropbox-API-Path-Root'
5253HTTP_STATUS_INVALID_PATH_ROOT = 422
54+ TOKEN_EXPIRATION_BUFFER = 300
5355
5456class 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