Skip to content

Commit 1a31f0d

Browse files
authored
Automatically retry JWT auth API calls that fail because of the exp c… (#249)
Automatically retry JWT auth API calls that fail because of the exp claim. JWT auth requires an expiration time, but legitimate authorization requests can be rejected if the system times differ between the request originator and the Box servers. This commit adds logic to detect this situation and automatically retry. The Box servers include a ``Date`` header when the auth request is rejected due to the exp claim, so we use that value to construct the new claim.
1 parent 4c2a570 commit 1a31f0d

File tree

4 files changed

+150
-6
lines changed

4 files changed

+150
-6
lines changed

HISTORY.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ Release History
8686
- ``authenticate_instance()`` now accepts an ``enterprise`` argument, which
8787
can be used to set and authenticate as the enterprise service account user,
8888
if ``None`` was passed for ``enterprise_id`` at construction time.
89+
- Authentications that fail due to the expiration time not falling within the
90+
correct window of time are now automatically retried using the time given
91+
in the Date header of the Box API response. This can happen naturally when
92+
the system time of the machine running the Box SDK doesn't agree with the
93+
system time of the Box API servers.
8994

9095
- Added an ``Event`` class.
9196
- Moved ``metadata()`` method to ``Item`` so it's now available for ``Folder``

boxsdk/auth/jwt_auth.py

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import jwt
1414
from six import binary_type, string_types, raise_from, text_type
1515

16+
from ..exception import BoxOAuthException
1617
from .oauth2 import OAuth2
1718
from ..object.user import User
1819
from ..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.

boxsdk/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
from __future__ import unicode_literals, absolute_import
44

55

6-
__version__ = '2.0.0a10'
6+
__version__ = '2.0.0a11'

test/unit/auth/test_jwt_auth.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313
from cryptography.hazmat.backends import default_backend
1414
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, generate_private_key as generate_rsa_private_key
1515
from cryptography.hazmat.primitives import serialization
16-
from mock import Mock, mock_open, patch, sentinel
16+
from mock import Mock, mock_open, patch, sentinel, call
1717
import pytest
18+
import pytz
19+
import requests
1820
from six import binary_type, string_types, text_type
1921

2022
from boxsdk.auth.jwt_auth import JWTAuth
23+
from boxsdk.exception import BoxOAuthException
2124
from boxsdk.config import API
2225
from boxsdk.object.user import User
2326
from boxsdk.util.compat import total_seconds
@@ -461,3 +464,54 @@ def test_from_settings_dictionary(
461464
):
462465
jwt_auth_from_dictionary = jwt_subclass_that_just_stores_params.from_settings_dictionary(json.loads(app_config_json_content))
463466
assert_jwt_kwargs_expected(jwt_auth_from_dictionary)
467+
468+
469+
@pytest.fixture
470+
def expect_auth_retry(status_code, error_description, include_date_header, error_code):
471+
return status_code == 400 and 'exp' in error_description and include_date_header and error_code == 'invalid_grant'
472+
473+
474+
@pytest.fixture
475+
def box_datetime():
476+
return datetime.now(tz=pytz.utc) - timedelta(100)
477+
478+
479+
@pytest.fixture
480+
def unsuccessful_jwt_response(box_datetime, status_code, error_description, include_date_header, error_code):
481+
headers = {'Date': box_datetime.strftime('%a, %d %b %Y %H:%M:%S %Z')} if include_date_header else {}
482+
unsuccessful_response = Mock(requests.Response(), headers=headers)
483+
unsuccessful_response.json.return_value = {'error_description': error_description, 'error': error_code}
484+
unsuccessful_response.status_code = status_code
485+
unsuccessful_response.ok = False
486+
return unsuccessful_response
487+
488+
489+
@pytest.mark.parametrize('jwt_algorithm', ('RS512',))
490+
@pytest.mark.parametrize('rsa_passphrase', (None,))
491+
@pytest.mark.parametrize('pass_private_key_by_path', (False,))
492+
@pytest.mark.parametrize('status_code', (400, 401, 429, 500))
493+
@pytest.mark.parametrize('error_description', ('invalid box_sub_type claim', 'invalid kid', "check the 'exp' claim"))
494+
@pytest.mark.parametrize('error_code', ('invalid_grant', 'bad_request'))
495+
@pytest.mark.parametrize('include_date_header', (True, False))
496+
def test_auth_retry_for_invalid_exp_claim(
497+
jwt_auth_init_mocks,
498+
expect_auth_retry,
499+
unsuccessful_jwt_response,
500+
box_datetime,
501+
):
502+
# pylint:disable=redefined-outer-name
503+
enterprise_id = 'fake_enterprise_id'
504+
with jwt_auth_init_mocks(assert_authed=False) as params:
505+
auth = params[0]
506+
with patch.object(auth, '_construct_and_send_jwt_auth') as mock_send_jwt:
507+
mock_send_jwt.side_effect = [BoxOAuthException(400, network_response=unsuccessful_jwt_response), 'jwt_token']
508+
if not expect_auth_retry:
509+
with pytest.raises(BoxOAuthException):
510+
auth.authenticate_instance(enterprise_id)
511+
else:
512+
auth.authenticate_instance(enterprise_id)
513+
expected_calls = [call(enterprise_id, 'enterprise')]
514+
if expect_auth_retry:
515+
expected_calls.append(call(enterprise_id, 'enterprise', box_datetime.replace(microsecond=0, tzinfo=None)))
516+
assert len(mock_send_jwt.mock_calls) == len(expected_calls)
517+
mock_send_jwt.assert_has_calls(expected_calls)

0 commit comments

Comments
 (0)