diff --git a/boxsdk/auth/oauth2.py b/boxsdk/auth/oauth2.py index 9a7111d46..8cac23ae2 100644 --- a/boxsdk/auth/oauth2.py +++ b/boxsdk/auth/oauth2.py @@ -11,9 +11,10 @@ import six from six.moves.urllib.parse import urlencode, urlunsplit # pylint:disable=import-error,no-name-in-module -from boxsdk.network.default_network import DefaultNetwork from boxsdk.config import API from boxsdk.exception import BoxOAuthException +from boxsdk.network.default_network import DefaultNetwork +from boxsdk.object.base_api_json_object import BaseAPIJSONObject from boxsdk.util.text_enum import TextEnum @@ -31,6 +32,11 @@ class TokenScope(TextEnum): ITEM_DOWNLOAD = 'item_download' +class TokenResponse(BaseAPIJSONObject): + """ Represents the response for a token request. """ + pass + + class OAuth2(object): """ Responsible for handling OAuth2 for the Box API. Can authenticate and refresh tokens. @@ -294,7 +300,7 @@ def _update_current_tokens(self, access_token, refresh_token): """ self._access_token, self._refresh_token = access_token, refresh_token - def _send_token_request_without_storing_tokens(self, data, access_token, expect_refresh_token=True): + def _execute_token_request(self, data, access_token, expect_refresh_token=True): """ Send the request to acquire or refresh an access token. @@ -307,9 +313,9 @@ def _send_token_request_without_storing_tokens(self, data, access_token, expect_ :type access_token: `unicode` or None :return: - The access token and refresh token. + The response for the token request. :rtype: - (`unicode`, `unicode`) + :class:`TokenResponse` """ self._check_closed() url = '{base_auth_url}/token'.format(base_auth_url=API.OAUTH2_API_URL) @@ -324,15 +330,14 @@ def _send_token_request_without_storing_tokens(self, data, access_token, expect_ if not network_response.ok: raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST') try: - response = network_response.json() - access_token = response['access_token'] - refresh_token = response.get('refresh_token', None) - if refresh_token is None and expect_refresh_token: - raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST') - except (ValueError, KeyError): + token_response = TokenResponse(network_response.json()) + except ValueError: raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST') - return access_token, refresh_token + if ('access_token' not in token_response) or (expect_refresh_token and 'refresh_token' not in token_response): + raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST') + + return token_response def send_token_request(self, data, access_token, expect_refresh_token=True): """ @@ -351,8 +356,10 @@ def send_token_request(self, data, access_token, expect_refresh_token=True): :rtype: (`unicode`, `unicode`) """ - access_token, refresh_token = self._send_token_request_without_storing_tokens(data, access_token, expect_refresh_token) - self._store_tokens(access_token, refresh_token) + token_response = self._execute_token_request(data, access_token, expect_refresh_token) + # pylint:disable=no-member + refresh_token = token_response.refresh_token if 'refresh_token' in token_response else None + self._store_tokens(token_response.access_token, refresh_token) return self._access_token, self._refresh_token def revoke(self): @@ -381,7 +388,7 @@ def revoke(self): def downscope_token(self, scopes, item=None, additional_data=None): """ - Get a downscoped token for the provided file or folder with the provided scopes. + 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. @@ -397,9 +404,9 @@ def downscope_token(self, scopes, item=None, additional_data=None): :type additional_data: `dict` :return: - The downscoped token + The response for the downscope token request. :rtype: - `unicode` + :class:`TokenResponse` """ self._check_closed() with self._refresh_lock: @@ -416,8 +423,7 @@ def downscope_token(self, scopes, item=None, additional_data=None): if additional_data: data.update(additional_data) - access_token, _ = self._send_token_request_without_storing_tokens(data, access_token, expect_refresh_token=False) - return access_token + 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/version.py b/boxsdk/version.py index cfe149c5a..9095d34ff 100644 --- a/boxsdk/version.py +++ b/boxsdk/version.py @@ -3,4 +3,4 @@ from __future__ import unicode_literals, absolute_import -__version__ = '2.0.0a8' +__version__ = '2.0.0a9' diff --git a/test/unit/auth/test_oauth2.py b/test/unit/auth/test_oauth2.py index 830ca16b4..785965919 100644 --- a/test/unit/auth/test_oauth2.py +++ b/test/unit/auth/test_oauth2.py @@ -346,6 +346,45 @@ 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', [ @@ -356,72 +395,34 @@ def test_revoke_sends_revoke_request( ], ) def test_downscope_token_sends_downscope_request( - oauth, access_token, - mock_network_layer, - mock_box_session, - mock_object_id, - make_mock_box_request, + check_downscope_token_request, item_class, scopes, expected_scopes, ): - mock_downscoped_token = 'mock_downscoped_token' - mock_network_response, _ = make_mock_box_request(response={'access_token': mock_downscoped_token}) - mock_network_layer.request.return_value = mock_network_response - - item = item_class(mock_box_session, mock_object_id) if item_class else None - downscoped_token = oauth.downscope_token(scopes, item) - - assert downscoped_token == mock_downscoped_token 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', } - 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, - ) + check_downscope_token_request(access_token, item_class, scopes, {}, expected_data) def test_downscope_token_sends_downscope_request_with_additional_data( - oauth, access_token, - mock_network_layer, - mock_box_session, - mock_object_id, - make_mock_box_request, + check_downscope_token_request, ): - mock_downscoped_token = 'mock_downscoped_token' - mock_network_response, _ = make_mock_box_request(response={'access_token': mock_downscoped_token}) - mock_network_layer.request.return_value = mock_network_response - - item = File(mock_box_session, mock_object_id) additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'} - downscoped_token = oauth.downscope_token([TokenScope.ITEM_READWRITE], item, additional_data) - - assert downscoped_token == mock_downscoped_token - mock_network_layer.request.assert_called_once_with( - 'POST', - '{0}/token'.format(API.OAUTH2_API_URL), - data={ - 'subject_token': access_token, - 'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token', - 'scope': 'item_readwrite', - 'resource': item.get_url(), - 'grant_type': 'new_grant_type', - 'extra_data_key': 'extra_data_value', - }, - headers={'content-type': 'application/x-www-form-urlencoded'}, - access_token=access_token, - ) + 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):