Repository: box/box-python-sdk
Pull request #247 (2017-10-03 20:49:57)

Add downscope_token() to client

boxcla: Verified that @anthonywee has signed the CLA. Thanks for the pull request!
anthonywee: This is a PR to compare 2 options for allowing for retries of a failed downscope_token() request. The other PR is #246 


jmoldow: I think this is the best approach, though I think #246 is a good change in its own right.

Can you remove `downscope_token` from `OAuth2`, and also update the HISTORY file?

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index c75fe80..9b00dca 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -102,7 +102,7 @@ Release History
   These classes manage the logic of constructing requests to an endpoint and storing the results,
   then provide ``__next__`` to easily iterate over the results. The option to return results one
   by one or as a ``Page`` of results is also provided.
-- Added a ``downscope_token()`` method to the ``OAuth2`` class. This generates a token that
+- Added a ``downscope_token()`` method to the ``Client`` class. This generates a token that
   has its permissions reduced to the provided scopes and for the optionally provided 
   ``File`` or ``Folder``.
 - Added methods for configuring ``JWTAuth`` from config file: ``JWTAuth.from_settings_file`` and
diff --git a/boxsdk/auth/oauth2.py b/boxsdk/auth/oauth2.py
index 8cac23a..48cb31b 100644
--- a/boxsdk/auth/oauth2.py
+++ b/boxsdk/auth/oauth2.py
@@ -386,45 +386,6 @@ def revoke(self):
                 raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
             self._store_tokens(None, None)
 
-    def downscope_token(self, scopes, item=None, additional_data=None):
-        """
-        Generate a downscoped token for the provided file or folder with the provided scopes.
-
-        :param scope:
-            The scope(s) to apply to the resulting token.
-        :type scopes:
-            `Iterable` of :class:`TokenScope`
-        :param item:
-            (Optional) The file or folder to get a downscoped token for. If None, the resulting token will
-            not be scoped down to just a single item.
-        :type item:
-            :class:`Item`
-        :param additional_data:
-            (Optional) Key value pairs which can be used to add/update the default data values in the request.
-        :type additional_data:
-            `dict`
-        :return:
-            The response for the downscope token request.
-        :rtype:
-            :class:`TokenResponse`
-        """
-        self._check_closed()
-        with self._refresh_lock:
-            self._check_closed()
-            access_token, _ = self._get_and_update_current_tokens()
-            data = {
-                'subject_token': access_token,
-                'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-                'scope': ' '.join(scopes),
-                'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
-            }
-            if item:
-                data['resource'] = item.get_url()
-            if additional_data:
-                data.update(additional_data)
-
-            return self._execute_token_request(data, access_token, expect_refresh_token=False)
-
     def close(self, revoke=True):
         """Close the auth object.
 
diff --git a/boxsdk/client/client.py b/boxsdk/client/client.py
index 8a7c825..8feee40 100644
--- a/boxsdk/client/client.py
+++ b/boxsdk/client/client.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals, absolute_import
 import json
 
+from ..auth.oauth2 import TokenResponse
 from ..config import API
 from ..session.box_session import BoxSession
 from ..network.default_network import DefaultNetwork
@@ -438,6 +439,43 @@ def create_user(self, name, login=None, **user_attributes):
             response_object=response,
         )
 
+    def downscope_token(self, scopes, item=None, additional_data=None):
+        """
+        Generate a downscoped token for the provided file or folder with the provided scopes.
+
+        :param scope:
+            The scope(s) to apply to the resulting token.
+        :type scopes:
+            `Iterable` of :class:`TokenScope`
+        :param item:
+            (Optional) The file or folder to get a downscoped token for. If None, the resulting token will
+            not be scoped down to just a single item.
+        :type item:
+            :class:`Item`
+        :param additional_data:
+            (Optional) Key value pairs which can be used to add/update the default data values in the request.
+        :type additional_data:
+            `dict`
+        :return:
+            The response for the downscope token request.
+        :rtype:
+            :class:`TokenResponse`
+        """
+        url = '{base_auth_url}/token'.format(base_auth_url=API.OAUTH2_API_URL)
+        data = {
+            'subject_token': self.auth.access_token,
+            'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+            'scope': ' '.join(scopes),
+            'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
+        }
+        if item:
+            data['resource'] = item.get_url()
+        if additional_data:
+            data.update(additional_data)
+
+        box_response = self._session.post(url, data=data)
+        return TokenResponse(box_response.json())
+
     def clone(self, session=None):
         """Base class override."""
         return self.__class__(oauth=self._oauth, session=(session or self._session))


Test patch:
diff --git a/test/unit/auth/test_oauth2.py b/test/unit/auth/test_oauth2.py
index 7859659..798af37 100644
--- a/test/unit/auth/test_oauth2.py
+++ b/test/unit/auth/test_oauth2.py
@@ -14,10 +14,8 @@
 
 from boxsdk.exception import BoxOAuthException
 from boxsdk.network.default_network import DefaultNetworkResponse
-from boxsdk.auth.oauth2 import OAuth2, TokenScope
+from boxsdk.auth.oauth2 import OAuth2
 from boxsdk.config import API
-from boxsdk.object.file import File
-from boxsdk.object.folder import Folder
 
 
 class MyError(Exception):
@@ -200,20 +198,17 @@ def refresh_tokens_and_verify_the_response():
 
 
 @pytest.fixture()
-def token_method(request, mock_box_session, mock_object_id):
+def token_method(request):
     """ Fixture that returns a partial method based on the method provided in request.param"""
     if request.param == OAuth2.refresh:
         return partial(OAuth2.refresh, access_token_to_refresh='fake_access_token')
     elif request.param == OAuth2.authenticate:
         return partial(OAuth2.authenticate, auth_code='fake_code')
-    elif request.param == OAuth2.downscope_token:
-        item = File(mock_box_session, mock_object_id)
-        return partial(OAuth2.downscope_token, scopes=[TokenScope.ITEM_READ], item=item)
 
 
 @pytest.mark.parametrize(
     'token_method',
-    [OAuth2.refresh, OAuth2.authenticate, OAuth2.downscope_token],
+    [OAuth2.refresh, OAuth2.authenticate],
     indirect=True,
 )
 def test_token_request_raises_box_oauth_exception_when_getting_bad_network_response(
@@ -234,7 +229,7 @@ def test_token_request_raises_box_oauth_exception_when_getting_bad_network_respo
 
 @pytest.mark.parametrize(
     'token_method',
-    [OAuth2.refresh, OAuth2.authenticate, OAuth2.downscope_token],
+    [OAuth2.refresh, OAuth2.authenticate],
     indirect=True,
 )
 def test_token_request_raises_box_oauth_exception_when_no_json_object_can_be_decoded(
@@ -346,85 +341,6 @@ def test_revoke_sends_revoke_request(
     assert oauth.access_token is None
 
 
-@pytest.fixture
-def check_downscope_token_request(
-        oauth,
-        mock_network_layer,
-        mock_box_session,
-        mock_object_id,
-        make_mock_box_request,
-):
-    def do_check(access_token, item_class, scopes, additional_data, expected_data):
-        dummy_downscoped_token = 'dummy_downscoped_token'
-        dummy_expires_in = 1234
-        mock_network_response, _ = make_mock_box_request(
-            response={'access_token': dummy_downscoped_token, 'expires_in': dummy_expires_in},
-        )
-        mock_network_layer.request.return_value = mock_network_response
-
-        item = item_class(mock_box_session, mock_object_id) if item_class else None
-
-        if additional_data:
-            downscoped_token_response = oauth.downscope_token(scopes, item, additional_data)
-        else:
-            downscoped_token_response = oauth.downscope_token(scopes, item)
-
-        assert downscoped_token_response.access_token == dummy_downscoped_token
-        assert downscoped_token_response.expires_in == dummy_expires_in
-
-        if item:
-            expected_data['resource'] = item.get_url()
-        mock_network_layer.request.assert_called_once_with(
-            'POST',
-            '{0}/token'.format(API.OAUTH2_API_URL),
-            data=expected_data,
-            headers={'content-type': 'application/x-www-form-urlencoded'},
-            access_token=access_token,
-        )
-
-    return do_check
-
-
-@pytest.mark.parametrize(
-    'item_class,scopes,expected_scopes',
-    [
-        (File, [TokenScope.ITEM_READWRITE], 'item_readwrite'),
-        (Folder, [TokenScope.ITEM_PREVIEW, TokenScope.ITEM_SHARE], 'item_preview item_share'),
-        (File, [TokenScope.ITEM_READ, TokenScope.ITEM_SHARE, TokenScope.ITEM_DELETE], 'item_read item_share item_delete'),
-        (None, [TokenScope.ITEM_DOWNLOAD], 'item_download'),
-    ],
-)
-def test_downscope_token_sends_downscope_request(
-        access_token,
-        check_downscope_token_request,
-        item_class,
-        scopes,
-        expected_scopes,
-):
-    expected_data = {
-        'subject_token': access_token,
-        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-        'scope': expected_scopes,
-        'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
-    }
-    check_downscope_token_request(access_token, item_class, scopes, {}, expected_data)
-
-
-def test_downscope_token_sends_downscope_request_with_additional_data(
-        access_token,
-        check_downscope_token_request,
-):
-    additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'}
-    expected_data = {
-        'subject_token': access_token,
-        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
-        'scope': 'item_readwrite',
-        'grant_type': 'new_grant_type',
-        'extra_data_key': 'extra_data_value',
-    }
-    check_downscope_token_request(access_token, File, [TokenScope.ITEM_READWRITE], additional_data, expected_data)
-
-
 def test_tokens_get_updated_after_noop_refresh(client_id, client_secret, access_token, new_access_token, refresh_token, mock_network_layer):
     """`OAuth2` object should update its state with new tokens, after no-op refresh.
 
diff --git a/test/unit/client/test_client.py b/test/unit/client/test_client.py
index a522d49..7dfcc9f 100644
--- a/test/unit/client/test_client.py
+++ b/test/unit/client/test_client.py
@@ -13,7 +13,7 @@
 # pylint:enable=redefined-builtin
 # pylint:enable=import-error
 
-from boxsdk.auth.oauth2 import OAuth2
+from boxsdk.auth.oauth2 import OAuth2, TokenScope
 from boxsdk.client import Client, DeveloperTokenClient, DevelopmentClient, LoggingClient
 from boxsdk.config import API
 from boxsdk.network.default_network import DefaultNetworkResponse
@@ -368,3 +368,78 @@ def test_create_enterprise_user_returns_the_correct_user_object(mock_client, moc
     assert isinstance(new_user, User)
     assert new_user.object_id == 1234
     assert new_user.name == test_user_name
+
+
+@pytest.fixture
+def check_downscope_token_request(
+        mock_client,
+        mock_box_session,
+        mock_object_id,
+        make_mock_box_request,
+):
+    def do_check(item_class, scopes, additional_data, expected_data):
+        dummy_downscoped_token = 'dummy_downscoped_token'
+        dummy_expires_in = 1234
+        mock_box_response, _ = make_mock_box_request(
+            response={'access_token': dummy_downscoped_token, 'expires_in': dummy_expires_in},
+        )
+        mock_box_session.post.return_value = mock_box_response
+
+        item = item_class(mock_box_session, mock_object_id) if item_class else None
+
+        if additional_data:
+            downscoped_token_response = mock_client.downscope_token(scopes, item, additional_data)
+        else:
+            downscoped_token_response = mock_client.downscope_token(scopes, item)
+
+        assert downscoped_token_response.access_token == dummy_downscoped_token
+        assert downscoped_token_response.expires_in == dummy_expires_in
+
+        if item:
+            expected_data['resource'] = item.get_url()
+        mock_box_session.post.assert_called_once_with(
+            '{0}/token'.format(API.OAUTH2_API_URL),
+            data=expected_data,
+        )
+
+    return do_check
+
+
+@pytest.mark.parametrize(
+    'item_class,scopes,expected_scopes',
+    [
+        (File, [TokenScope.ITEM_READWRITE], 'item_readwrite'),
+        (Folder, [TokenScope.ITEM_PREVIEW, TokenScope.ITEM_SHARE], 'item_preview item_share'),
+        (File, [TokenScope.ITEM_READ, TokenScope.ITEM_SHARE, TokenScope.ITEM_DELETE], 'item_read item_share item_delete'),
+        (None, [TokenScope.ITEM_DOWNLOAD], 'item_download'),
+    ],
+)
+def test_downscope_token_sends_downscope_request(
+        mock_client,
+        check_downscope_token_request,
+        item_class,
+        scopes,
+        expected_scopes,
+):
+    expected_data = {
+        'subject_token': mock_client.auth.access_token,
+        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+        'scope': expected_scopes,
+        'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
+    }
+    check_downscope_token_request(item_class, scopes, {}, expected_data)
+
+
+def test_downscope_token_sends_downscope_request_with_additional_data(
+        mock_client,
+        check_downscope_token_request,
+):
+    additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'}
+    expected_data = {
+        'subject_token': mock_client.auth.access_token,
+        'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
+        'scope': 'item_readwrite',
+        'grant_type': 'new_grant_type',
+        'extra_data_key': 'extra_data_value',
+    }
+    check_downscope_token_request(File, [TokenScope.ITEM_READWRITE], additional_data, expected_data)