1313import jwt
1414from six import binary_type , string_types , raise_from , text_type
1515
16+ from ..exception import BoxOAuthException
1617from .oauth2 import OAuth2
1718from ..object .user import User
1819from ..util .compat import NoneType , total_seconds
@@ -158,10 +159,11 @@ def __init__(
158159 self ._jwt_key_id = jwt_key_id
159160 self ._user_id = user_id
160161
161- def _auth_with_jwt (self , sub , sub_type ):
162+ def _construct_and_send_jwt_auth (self , sub , sub_type , now_time = None ):
162163 """
163- Get an access token for use with Box Developer Edition. Pass an enterprise ID to get an enterprise token
164- (which can be used to provision/deprovision users), or a user ID to get a user token.
164+ Construct the claims used for JWT auth and send a request to get a JWT.
165+ Pass an enterprise ID to get an enterprise token (which can be used to provision/deprovision users),
166+ or a user ID to get a user token.
165167
166168 :param sub:
167169 The enterprise ID or user ID to auth.
@@ -171,6 +173,11 @@ def _auth_with_jwt(self, sub, sub_type):
171173 Either 'enterprise' or 'user'
172174 :type sub_type:
173175 `unicode`
176+ :param now_time:
177+ Optional. The current UTC time is needed in order to construct the expiration time of the JWT claim.
178+ If None, `datetime.utcnow()` will be used.
179+ :type now_time:
180+ `datetime` or None
174181 :return:
175182 The access token for the enterprise or app user.
176183 :rtype:
@@ -181,7 +188,9 @@ def _auth_with_jwt(self, sub, sub_type):
181188 ascii_alphabet = string .ascii_letters + string .digits
182189 ascii_len = len (ascii_alphabet )
183190 jti = '' .join (ascii_alphabet [int (system_random .random () * ascii_len )] for _ in range (jti_length ))
184- now_plus_30 = datetime .utcnow () + timedelta (seconds = 30 )
191+ if now_time is None :
192+ now_time = datetime .utcnow ()
193+ now_plus_30 = now_time + timedelta (seconds = 30 )
185194 assertion = jwt .encode (
186195 {
187196 'iss' : self ._client_id ,
@@ -209,6 +218,82 @@ def _auth_with_jwt(self, sub, sub_type):
209218 data ['box_device_name' ] = self ._box_device_name
210219 return self .send_token_request (data , access_token = None , expect_refresh_token = False )[0 ]
211220
221+ def _auth_with_jwt (self , sub , sub_type ):
222+ """
223+ Auth with JWT.
224+ If authorization fails because the expiration time is out of sync with the Box servers,
225+ retry using the time returned in the error response.
226+ Pass an enterprise ID to get an enterprise token (which can be used to provision/deprovision users),
227+ or a user ID to get a user token.
228+
229+ :param sub:
230+ The enterprise ID or user ID to auth.
231+ :type sub:
232+ `unicode`
233+ :param sub_type:
234+ Either 'enterprise' or 'user'
235+ :type sub_type:
236+ `unicode`
237+ :return:
238+ The access token for the enterprise or app user.
239+ :rtype:
240+ `unicode`
241+ """
242+ try :
243+ return self ._construct_and_send_jwt_auth (sub , sub_type )
244+ except BoxOAuthException as ex :
245+ error_response = ex .network_response
246+ box_datetime = self ._get_date_header (error_response )
247+ if box_datetime is not None and self ._was_exp_claim_rejected_due_to_clock_skew (error_response ):
248+ return self ._construct_and_send_jwt_auth (sub , sub_type , box_datetime )
249+ raise
250+
251+ @staticmethod
252+ def _get_date_header (network_response ):
253+ """
254+ Get datetime object for Date header, if the Date header is available.
255+
256+ :param network_response:
257+ The response from the Box API that should include a Date header.
258+ :type network_response:
259+ :class:`Response`
260+ :return:
261+ The datetime parsed from the Date header, or None if the header is absent or if it couldn't be parsed.
262+ :rtype:
263+ `datetime` or `None`
264+ """
265+ box_date_header = network_response .headers .get ('Date' , None )
266+ if box_date_header is not None :
267+ try :
268+ return datetime .strptime (box_date_header , '%a, %d %b %Y %H:%M:%S %Z' )
269+ except ValueError :
270+ pass
271+ return None
272+
273+ @staticmethod
274+ def _was_exp_claim_rejected_due_to_clock_skew (network_response ):
275+ """
276+ Determine whether the network response indicates that the authorization request was rejected because of
277+ the exp claim. This can happen if the current system time is too different from the Box server time.
278+
279+ Returns True if the status code is 400, the error code is invalid_grant, and the error description indicates
280+ a problem with the exp claim; False, otherwise.
281+
282+ :param network_response:
283+ :type network_response:
284+ :class:`Response`
285+ :rtype:
286+ `bool`
287+ """
288+ status_code = network_response .status_code
289+ try :
290+ json_response = network_response .json ()
291+ except ValueError :
292+ return False
293+ error_code = json_response .get ('error' , '' )
294+ error_description = json_response .get ('error_description' , '' )
295+ return status_code == 400 and error_code == 'invalid_grant' and 'exp' in error_description
296+
212297 def authenticate_user (self , user = None ):
213298 """
214299 Get an access token for a User.
0 commit comments