Repository: box/box-python-sdk
Pull request #249 (2017-10-18 20:18:23)

Automatically retry JWT auth API calls that fail because of the exp c…
…laim.

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.

boxcla: Verified that @Jeff-Meadows has signed the CLA. Thanks for the pull request!

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index cb02d3b..19f44d6 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -86,6 +86,11 @@ Release History
   - ``authenticate_instance()`` now accepts an ``enterprise`` argument, which
     can be used to set and authenticate as the enterprise service account user,
     if ``None`` was passed for ``enterprise_id`` at construction time.
+  - Authentications that fail due to the expiration time not falling within the
+    correct window of time are now automatically retried using the time given
+    in the Date header of the Box API response. This can happen naturally when
+    the system time of the machine running the Box SDK doesn't agree with the
+    system time of the Box API servers.
 
 - Added an ``Event`` class.
 - Moved ``metadata()`` method to ``Item`` so it's now available for ``Folder``
diff --git a/boxsdk/auth/jwt_auth.py b/boxsdk/auth/jwt_auth.py
index 3a85130..0079f99 100644
--- a/boxsdk/auth/jwt_auth.py
+++ b/boxsdk/auth/jwt_auth.py
@@ -13,6 +13,7 @@
 import jwt
 from six import binary_type, string_types, raise_from, text_type
 
+from ..exception import BoxOAuthException
 from .oauth2 import OAuth2
 from ..object.user import User
 from ..util.compat import NoneType, total_seconds
@@ -158,10 +159,11 @@ def __init__(
         self._jwt_key_id = jwt_key_id
         self._user_id = user_id
 
-    def _auth_with_jwt(self, sub, sub_type):
+    def _construct_and_send_jwt_auth(self, sub, sub_type, now_time=None):
         """
-        Get an access token for use with Box Developer Edition. Pass an enterprise ID to get an enterprise token
-        (which can be used to provision/deprovision users), or a user ID to get a user token.
+        Construct the claims used for JWT auth and send a request to get a JWT.
+        Pass an enterprise ID to get an enterprise token (which can be used to provision/deprovision users),
+        or a user ID to get a user token.
 
         :param sub:
             The enterprise ID or user ID to auth.
@@ -171,6 +173,11 @@ def _auth_with_jwt(self, sub, sub_type):
             Either 'enterprise' or 'user'
         :type sub_type:
             `unicode`
+        :param now_time:
+            Optional. The current UTC time is needed in order to construct the expiration time of the JWT claim.
+            If None, `datetime.utcnow()` will be used.
+        :type now_time:
+            `datetime` or None
         :return:
             The access token for the enterprise or app user.
         :rtype:
@@ -181,7 +188,9 @@ def _auth_with_jwt(self, sub, sub_type):
         ascii_alphabet = string.ascii_letters + string.digits
         ascii_len = len(ascii_alphabet)
         jti = ''.join(ascii_alphabet[int(system_random.random() * ascii_len)] for _ in range(jti_length))
-        now_plus_30 = datetime.utcnow() + timedelta(seconds=30)
+        if now_time is None:
+            now_time = datetime.utcnow()
+        now_plus_30 = now_time + timedelta(seconds=30)
         assertion = jwt.encode(
             {
                 'iss': self._client_id,
@@ -209,6 +218,82 @@ def _auth_with_jwt(self, sub, sub_type):
             data['box_device_name'] = self._box_device_name
         return self.send_token_request(data, access_token=None, expect_refresh_token=False)[0]
 
+    def _auth_with_jwt(self, sub, sub_type):
+        """
+        Auth with JWT.
+        If authorization fails because the expiration time is out of sync with the Box servers,
+        retry using the time returned in the error response.
+        Pass an enterprise ID to get an enterprise token (which can be used to provision/deprovision users),
+        or a user ID to get a user token.
+
+        :param sub:
+            The enterprise ID or user ID to auth.
+        :type sub:
+            `unicode`
+        :param sub_type:
+            Either 'enterprise' or 'user'
+        :type sub_type:
+            `unicode`
+        :return:
+            The access token for the enterprise or app user.
+        :rtype:
+            `unicode`
+        """
+        try:
+            return self._construct_and_send_jwt_auth(sub, sub_type)
+        except BoxOAuthException as ex:
+            error_response = ex.network_response
+            box_datetime = self._get_date_header(error_response)
+            if box_datetime is not None and self._was_exp_claim_rejected_due_to_clock_skew(error_response):
+                return self._construct_and_send_jwt_auth(sub, sub_type, box_datetime)
+            raise
+
+    @staticmethod
+    def _get_date_header(network_response):
+        """
+        Get datetime object for Date header, if the Date header is available.
+
+        :param network_response:
+            The response from the Box API that should include a Date header.
+        :type network_response:
+            :class:`Response`
+        :return:
+            The datetime parsed from the Date header, or None if the header is absent or if it couldn't be parsed.
+        :rtype:
+            `datetime` or `None`
+        """
+        box_date_header = network_response.headers.get('Date', None)
+        if box_date_header is not None:
+            try:
+                return datetime.strptime(box_date_header, '%a, %d %b %Y %H:%M:%S %Z')
+            except ValueError:
+                pass
+        return None
+
+    @staticmethod
+    def _was_exp_claim_rejected_due_to_clock_skew(network_response):
+        """
+        Determine whether the network response indicates that the authorization request was rejected because of
+        the exp claim. This can happen if the current system time is too different from the Box server time.
+
+        Returns True if the status code is 400, the error code is invalid_grant, and the error description indicates
+        a problem with the exp claim; False, otherwise.
+
+        :param network_response:
+        :type network_response:
+            :class:`Response`
+        :rtype:
+            `bool`
+        """
+        status_code = network_response.status_code
+        try:
+            json_response = network_response.json()
+        except ValueError:
+            return False
+        error_code = json_response.get('error', '')
+        error_description = json_response.get('error_description', '')
+        return status_code == 400 and error_code == 'invalid_grant' and 'exp' in error_description
+
     def authenticate_user(self, user=None):
         """
         Get an access token for a User.
diff --git a/boxsdk/version.py b/boxsdk/version.py
index 5ff1bc6..bfb0a18 100644
--- a/boxsdk/version.py
+++ b/boxsdk/version.py
@@ -3,4 +3,4 @@
 from __future__ import unicode_literals, absolute_import
 
 
-__version__ = '2.0.0a10'
+__version__ = '2.0.0a11'


Test patch:
diff --git a/test/unit/auth/test_jwt_auth.py b/test/unit/auth/test_jwt_auth.py
index 4512397..eba14c8 100644
--- a/test/unit/auth/test_jwt_auth.py
+++ b/test/unit/auth/test_jwt_auth.py
@@ -13,11 +13,14 @@
 from cryptography.hazmat.backends import default_backend
 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, generate_private_key as generate_rsa_private_key
 from cryptography.hazmat.primitives import serialization
-from mock import Mock, mock_open, patch, sentinel
+from mock import Mock, mock_open, patch, sentinel, call
 import pytest
+import pytz
+import requests
 from six import binary_type, string_types, text_type
 
 from boxsdk.auth.jwt_auth import JWTAuth
+from boxsdk.exception import BoxOAuthException
 from boxsdk.config import API
 from boxsdk.object.user import User
 from boxsdk.util.compat import total_seconds
@@ -461,3 +464,54 @@ def test_from_settings_dictionary(
 ):
     jwt_auth_from_dictionary = jwt_subclass_that_just_stores_params.from_settings_dictionary(json.loads(app_config_json_content))
     assert_jwt_kwargs_expected(jwt_auth_from_dictionary)
+
+
+@pytest.fixture
+def expect_auth_retry(status_code, error_description, include_date_header, error_code):
+    return status_code == 400 and 'exp' in error_description and include_date_header and error_code == 'invalid_grant'
+
+
+@pytest.fixture
+def box_datetime():
+    return datetime.now(tz=pytz.utc) - timedelta(100)
+
+
+@pytest.fixture
+def unsuccessful_jwt_response(box_datetime, status_code, error_description, include_date_header, error_code):
+    headers = {'Date': box_datetime.strftime('%a, %d %b %Y %H:%M:%S %Z')} if include_date_header else {}
+    unsuccessful_response = Mock(requests.Response(), headers=headers)
+    unsuccessful_response.json.return_value = {'error_description': error_description, 'error': error_code}
+    unsuccessful_response.status_code = status_code
+    unsuccessful_response.ok = False
+    return unsuccessful_response
+
+
+@pytest.mark.parametrize('jwt_algorithm', ('RS512',))
+@pytest.mark.parametrize('rsa_passphrase', (None,))
+@pytest.mark.parametrize('pass_private_key_by_path', (False,))
+@pytest.mark.parametrize('status_code', (400, 401, 429, 500))
+@pytest.mark.parametrize('error_description', ('invalid box_sub_type claim', 'invalid kid', "check the 'exp' claim"))
+@pytest.mark.parametrize('error_code', ('invalid_grant', 'bad_request'))
+@pytest.mark.parametrize('include_date_header', (True, False))
+def test_auth_retry_for_invalid_exp_claim(
+        jwt_auth_init_mocks,
+        expect_auth_retry,
+        unsuccessful_jwt_response,
+        box_datetime,
+):
+    # pylint:disable=redefined-outer-name
+    enterprise_id = 'fake_enterprise_id'
+    with jwt_auth_init_mocks(assert_authed=False) as params:
+        auth = params[0]
+        with patch.object(auth, '_construct_and_send_jwt_auth') as mock_send_jwt:
+            mock_send_jwt.side_effect = [BoxOAuthException(400, network_response=unsuccessful_jwt_response), 'jwt_token']
+            if not expect_auth_retry:
+                with pytest.raises(BoxOAuthException):
+                    auth.authenticate_instance(enterprise_id)
+            else:
+                auth.authenticate_instance(enterprise_id)
+            expected_calls = [call(enterprise_id, 'enterprise')]
+            if expect_auth_retry:
+                expected_calls.append(call(enterprise_id, 'enterprise', box_datetime.replace(microsecond=0, tzinfo=None)))
+            assert len(mock_send_jwt.mock_calls) == len(expected_calls)
+            mock_send_jwt.assert_has_calls(expected_calls)