Skip to content

Commit 5307f8f

Browse files
anthonyweejmoldow
authored andcommitted
Add OAuth2.downscope_token() method (#230)
This allows the caller to obtain a token with its permissions reduced to the provided scope(s) and optionally for the provided file or folder. Bump version to 2.0.0a8. Fixes #229.
1 parent a764630 commit 5307f8f

File tree

4 files changed

+200
-23
lines changed

4 files changed

+200
-23
lines changed

HISTORY.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ Release History
102102
These classes manage the logic of constructing requests to an endpoint and storing the results,
103103
then provide ``__next__`` to easily iterate over the results. The option to return results one
104104
by one or as a ``Page`` of results is also provided.
105+
- Added a ``downscope_token()`` method to the ``OAuth2`` class. This generates a token that
106+
has its permissions reduced to the provided scopes and for the optionally provided
107+
``File`` or ``Folder``.
105108

106109
**Other**
107110

boxsdk/auth/oauth2.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@
1414
from boxsdk.network.default_network import DefaultNetwork
1515
from boxsdk.config import API
1616
from boxsdk.exception import BoxOAuthException
17+
from boxsdk.util.text_enum import TextEnum
18+
19+
20+
class TokenScope(TextEnum):
21+
""" Scopes used for a downscope token request.
22+
23+
See https://developer.box.com/reference#token-exchange.
24+
"""
25+
ITEM_READ = 'item_read'
26+
ITEM_READWRITE = 'item_readwrite'
27+
ITEM_PREVIEW = 'item_preview'
28+
ITEM_UPLOAD = 'item_upload'
29+
ITEM_SHARE = 'item_share'
30+
ITEM_DELETE = 'item_delete'
31+
ITEM_DOWNLOAD = 'item_download'
1732

1833

1934
class OAuth2(object):
@@ -279,7 +294,7 @@ def _update_current_tokens(self, access_token, refresh_token):
279294
"""
280295
self._access_token, self._refresh_token = access_token, refresh_token
281296

282-
def send_token_request(self, data, access_token, expect_refresh_token=True):
297+
def _send_token_request_without_storing_tokens(self, data, access_token, expect_refresh_token=True):
283298
"""
284299
Send the request to acquire or refresh an access token.
285300
@@ -316,6 +331,27 @@ def send_token_request(self, data, access_token, expect_refresh_token=True):
316331
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
317332
except (ValueError, KeyError):
318333
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
334+
335+
return access_token, refresh_token
336+
337+
def send_token_request(self, data, access_token, expect_refresh_token=True):
338+
"""
339+
Send the request to acquire or refresh an access token, and store the tokens.
340+
341+
:param data:
342+
Dictionary containing the request parameters as specified by the Box API.
343+
:type data:
344+
`dict`
345+
:param access_token:
346+
The current access token.
347+
:type access_token:
348+
`unicode` or None
349+
:return:
350+
The access token and refresh token.
351+
:rtype:
352+
(`unicode`, `unicode`)
353+
"""
354+
access_token, refresh_token = self._send_token_request_without_storing_tokens(data, access_token, expect_refresh_token)
319355
self._store_tokens(access_token, refresh_token)
320356
return self._access_token, self._refresh_token
321357

@@ -343,6 +379,46 @@ def revoke(self):
343379
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
344380
self._store_tokens(None, None)
345381

382+
def downscope_token(self, scopes, item=None, additional_data=None):
383+
"""
384+
Get a downscoped token for the provided file or folder with the provided scopes.
385+
386+
:param scope:
387+
The scope(s) to apply to the resulting token.
388+
:type scopes:
389+
`Iterable` of :class:`TokenScope`
390+
:param item:
391+
(Optional) The file or folder to get a downscoped token for. If None, the resulting token will
392+
not be scoped down to just a single item.
393+
:type item:
394+
:class:`Item`
395+
:param additional_data:
396+
(Optional) Key value pairs which can be used to add/update the default data values in the request.
397+
:type additional_data:
398+
`dict`
399+
:return:
400+
The downscoped token
401+
:rtype:
402+
`unicode`
403+
"""
404+
self._check_closed()
405+
with self._refresh_lock:
406+
self._check_closed()
407+
access_token, _ = self._get_and_update_current_tokens()
408+
data = {
409+
'subject_token': access_token,
410+
'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
411+
'scope': ' '.join(scopes),
412+
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
413+
}
414+
if item:
415+
data['resource'] = item.get_url()
416+
if additional_data:
417+
data.update(additional_data)
418+
419+
access_token, _ = self._send_token_request_without_storing_tokens(data, access_token, expect_refresh_token=False)
420+
return access_token
421+
346422
def close(self, revoke=True):
347423
"""Close the auth object.
348424

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.0a7'
6+
__version__ = '2.0.0a8'

test/unit/auth/test_oauth2.py

Lines changed: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414

1515
from boxsdk.exception import BoxOAuthException
1616
from boxsdk.network.default_network import DefaultNetworkResponse
17-
from boxsdk.auth.oauth2 import OAuth2
17+
from boxsdk.auth.oauth2 import OAuth2, TokenScope
1818
from boxsdk.config import API
19+
from boxsdk.object.file import File
20+
from boxsdk.object.folder import Folder
1921

2022

2123
class MyError(Exception):
@@ -197,12 +199,25 @@ def refresh_tokens_and_verify_the_response():
197199
thread.join()
198200

199201

200-
@pytest.mark.parametrize('test_method', [
201-
partial(OAuth2.refresh, access_token_to_refresh='fake_access_token'),
202-
partial(OAuth2.authenticate, auth_code='fake_code')
203-
])
202+
@pytest.fixture()
203+
def token_method(request, mock_box_session, mock_object_id):
204+
""" Fixture that returns a partial method based on the method provided in request.param"""
205+
if request.param == OAuth2.refresh:
206+
return partial(OAuth2.refresh, access_token_to_refresh='fake_access_token')
207+
elif request.param == OAuth2.authenticate:
208+
return partial(OAuth2.authenticate, auth_code='fake_code')
209+
elif request.param == OAuth2.downscope_token:
210+
item = File(mock_box_session, mock_object_id)
211+
return partial(OAuth2.downscope_token, scopes=[TokenScope.ITEM_READ], item=item)
212+
213+
214+
@pytest.mark.parametrize(
215+
'token_method',
216+
[OAuth2.refresh, OAuth2.authenticate, OAuth2.downscope_token],
217+
indirect=True,
218+
)
204219
def test_token_request_raises_box_oauth_exception_when_getting_bad_network_response(
205-
test_method,
220+
token_method,
206221
mock_network_layer,
207222
bad_network_response,
208223
):
@@ -214,15 +229,16 @@ def test_token_request_raises_box_oauth_exception_when_getting_bad_network_respo
214229
access_token='fake_access_token',
215230
network_layer=mock_network_layer,
216231
)
217-
test_method(oauth)
232+
token_method(oauth)
218233

219234

220-
@pytest.mark.parametrize('test_method', [
221-
partial(OAuth2.refresh, access_token_to_refresh='fake_access_token'),
222-
partial(OAuth2.authenticate, auth_code='fake_code')
223-
])
235+
@pytest.mark.parametrize(
236+
'token_method',
237+
[OAuth2.refresh, OAuth2.authenticate, OAuth2.downscope_token],
238+
indirect=True,
239+
)
224240
def test_token_request_raises_box_oauth_exception_when_no_json_object_can_be_decoded(
225-
test_method,
241+
token_method,
226242
mock_network_layer,
227243
non_json_response,
228244
):
@@ -234,7 +250,7 @@ def test_token_request_raises_box_oauth_exception_when_no_json_object_can_be_dec
234250
network_layer=mock_network_layer,
235251
)
236252
with pytest.raises(BoxOAuthException):
237-
test_method(oauth)
253+
token_method(oauth)
238254

239255

240256
@pytest.fixture(params=[
@@ -287,6 +303,17 @@ def test_token_request_allows_missing_refresh_token(mock_network_layer):
287303
oauth.send_token_request({}, access_token=None, expect_refresh_token=False)
288304

289305

306+
@pytest.fixture()
307+
def oauth(client_id, client_secret, access_token, refresh_token, mock_network_layer):
308+
return OAuth2(
309+
client_id=client_id,
310+
client_secret=client_secret,
311+
access_token=access_token,
312+
refresh_token=refresh_token,
313+
network_layer=mock_network_layer,
314+
)
315+
316+
290317
@pytest.mark.parametrize(
291318
'access_token,refresh_token,expected_token_to_revoke',
292319
(
@@ -299,19 +326,12 @@ def test_revoke_sends_revoke_request(
299326
client_secret,
300327
mock_network_layer,
301328
access_token,
302-
refresh_token,
329+
oauth,
303330
expected_token_to_revoke,
304331
):
305332
mock_network_response = Mock()
306333
mock_network_response.ok = True
307334
mock_network_layer.request.return_value = mock_network_response
308-
oauth = OAuth2(
309-
client_id=client_id,
310-
client_secret=client_secret,
311-
access_token=access_token,
312-
refresh_token=refresh_token,
313-
network_layer=mock_network_layer,
314-
)
315335
oauth.revoke()
316336
mock_network_layer.request.assert_called_once_with(
317337
'POST',
@@ -326,6 +346,84 @@ def test_revoke_sends_revoke_request(
326346
assert oauth.access_token is None
327347

328348

349+
@pytest.mark.parametrize(
350+
'item_class,scopes,expected_scopes',
351+
[
352+
(File, [TokenScope.ITEM_READWRITE], 'item_readwrite'),
353+
(Folder, [TokenScope.ITEM_PREVIEW, TokenScope.ITEM_SHARE], 'item_preview item_share'),
354+
(File, [TokenScope.ITEM_READ, TokenScope.ITEM_SHARE, TokenScope.ITEM_DELETE], 'item_read item_share item_delete'),
355+
(None, [TokenScope.ITEM_DOWNLOAD], 'item_download'),
356+
],
357+
)
358+
def test_downscope_token_sends_downscope_request(
359+
oauth,
360+
access_token,
361+
mock_network_layer,
362+
mock_box_session,
363+
mock_object_id,
364+
make_mock_box_request,
365+
item_class,
366+
scopes,
367+
expected_scopes,
368+
):
369+
mock_downscoped_token = 'mock_downscoped_token'
370+
mock_network_response, _ = make_mock_box_request(response={'access_token': mock_downscoped_token})
371+
mock_network_layer.request.return_value = mock_network_response
372+
373+
item = item_class(mock_box_session, mock_object_id) if item_class else None
374+
downscoped_token = oauth.downscope_token(scopes, item)
375+
376+
assert downscoped_token == mock_downscoped_token
377+
expected_data = {
378+
'subject_token': access_token,
379+
'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
380+
'scope': expected_scopes,
381+
'grant_type': 'urn:ietf:params:oauth:grant-type:token-exchange',
382+
}
383+
if item:
384+
expected_data['resource'] = item.get_url()
385+
mock_network_layer.request.assert_called_once_with(
386+
'POST',
387+
'{0}/token'.format(API.OAUTH2_API_URL),
388+
data=expected_data,
389+
headers={'content-type': 'application/x-www-form-urlencoded'},
390+
access_token=access_token,
391+
)
392+
393+
394+
def test_downscope_token_sends_downscope_request_with_additional_data(
395+
oauth,
396+
access_token,
397+
mock_network_layer,
398+
mock_box_session,
399+
mock_object_id,
400+
make_mock_box_request,
401+
):
402+
mock_downscoped_token = 'mock_downscoped_token'
403+
mock_network_response, _ = make_mock_box_request(response={'access_token': mock_downscoped_token})
404+
mock_network_layer.request.return_value = mock_network_response
405+
406+
item = File(mock_box_session, mock_object_id)
407+
additional_data = {'grant_type': 'new_grant_type', 'extra_data_key': 'extra_data_value'}
408+
downscoped_token = oauth.downscope_token([TokenScope.ITEM_READWRITE], item, additional_data)
409+
410+
assert downscoped_token == mock_downscoped_token
411+
mock_network_layer.request.assert_called_once_with(
412+
'POST',
413+
'{0}/token'.format(API.OAUTH2_API_URL),
414+
data={
415+
'subject_token': access_token,
416+
'subject_token_type': 'urn:ietf:params:oauth:token-type:access_token',
417+
'scope': 'item_readwrite',
418+
'resource': item.get_url(),
419+
'grant_type': 'new_grant_type',
420+
'extra_data_key': 'extra_data_value',
421+
},
422+
headers={'content-type': 'application/x-www-form-urlencoded'},
423+
access_token=access_token,
424+
)
425+
426+
329427
def test_tokens_get_updated_after_noop_refresh(client_id, client_secret, access_token, new_access_token, refresh_token, mock_network_layer):
330428
"""`OAuth2` object should update its state with new tokens, after no-op refresh.
331429

0 commit comments

Comments
 (0)