Skip to content

Commit a764630

Browse files
authored
Add functionality for auto-revoking auth tokens (#224)
Auth objects can now be closed, which prevents them from being used to request new tokens. This will also revoke any existing tokens. Also introduces a `closing()` context manager method, which will auto-close the auth object on exit. Clients can use this to make sure that their tokens don't live longer than they need them for.
1 parent 23a3cd2 commit a764630

File tree

6 files changed

+203
-3
lines changed

6 files changed

+203
-3
lines changed

HISTORY.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ Release History
5353
- When the ``auto_session_renewal`` is ``True`` when calling any of the request
5454
methods on ``BoxSession``, if there is no access token, ``BoxSession`` will
5555
renew the token _before_ making the request. This saves an API call.
56+
- Auth objects can now be closed, which prevents them from being used to
57+
request new tokens. This will also revoke any existing tokens (though that
58+
feature can be disabled by passing ``revoke=False``). Also introduces a
59+
``closing()`` context manager method, which will auto-close the auth object
60+
on exit.
5661
- Various enhancements to the ``JWTAuth`` baseclass:
5762

5863
- The ``authenticate_app_user()`` method is renamed to

boxsdk/auth/oauth2.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
from __future__ import unicode_literals
44

5+
from contextlib import contextmanager
56
from threading import Lock
67
import random
78
import string # pylint:disable=deprecated-module
9+
import sys
810

11+
import six
912
from six.moves.urllib.parse import urlencode, urlunsplit # pylint:disable=import-error,no-name-in-module
1013

1114
from boxsdk.network.default_network import DefaultNetwork
@@ -16,6 +19,11 @@
1619
class OAuth2(object):
1720
"""
1821
Responsible for handling OAuth2 for the Box API. Can authenticate and refresh tokens.
22+
23+
Can be used as a closeable resource, similar to a file. When `close()` is
24+
called, the current tokens are revoked, and the object is put into a state
25+
where it can no longer request new tokens. This action can also be managed
26+
with the `closing()` context manager method.
1927
"""
2028

2129
def __init__(
@@ -77,6 +85,7 @@ def __init__(
7785
self._refresh_lock = refresh_lock or Lock()
7886
self._box_device_id = box_device_id
7987
self._box_device_name = box_device_name
88+
self._closed = False
8089

8190
@property
8291
def access_token(self):
@@ -90,6 +99,16 @@ def access_token(self):
9099
"""
91100
return self._access_token
92101

102+
@property
103+
def closed(self):
104+
"""True iff the auth object has been closed.
105+
106+
When in the closed state, it can no longer request new tokens.
107+
108+
:rtype: `bool`
109+
"""
110+
return self._closed
111+
93112
def get_authorization_url(self, redirect_url):
94113
"""
95114
Get the authorization url based on the client id and the redirect url passed in
@@ -198,7 +217,9 @@ def refresh(self, access_token_to_refresh):
198217
:rtype:
199218
`tuple` of (`unicode`, (`unicode` or `None`))
200219
"""
220+
self._check_closed()
201221
with self._refresh_lock:
222+
self._check_closed()
202223
access_token, refresh_token = self._get_and_update_current_tokens()
203224
# The lock here is for handling that case that multiple requests fail, due to access token expired, at the
204225
# same time to avoid multiple session renewals.
@@ -275,6 +296,7 @@ def send_token_request(self, data, access_token, expect_refresh_token=True):
275296
:rtype:
276297
(`unicode`, `unicode`)
277298
"""
299+
self._check_closed()
278300
url = '{base_auth_url}/token'.format(base_auth_url=API.OAUTH2_API_URL)
279301
headers = {'content-type': 'application/x-www-form-urlencoded'}
280302
network_response = self._network_layer.request(
@@ -320,3 +342,72 @@ def revoke(self):
320342
if not network_response.ok:
321343
raise BoxOAuthException(network_response.status_code, network_response.content, url, 'POST')
322344
self._store_tokens(None, None)
345+
346+
def close(self, revoke=True):
347+
"""Close the auth object.
348+
349+
After this action is performed, the auth object can no longer request
350+
new tokens.
351+
352+
This method may be called even if the auth object is already closed.
353+
354+
:param revoke:
355+
(optional) Whether the current tokens should be revoked, via `revoke()`.
356+
Defaults to `True` as a security precaution, so that the tokens aren't usable
357+
by any adversaries after you are done with them.
358+
Note that the revoke isn't guaranteed to succeed (the network connection might
359+
fail, or the API call might respond with a non-200 HTTP response), so this
360+
isn't a fool-proof security mechanism.
361+
If the revoke fails, an exception is raised.
362+
The auth object is still considered to be closed, even if the revoke fails.
363+
:type revoke: `bool`
364+
"""
365+
self._closed = True
366+
if revoke:
367+
self.revoke()
368+
369+
@contextmanager
370+
def closing(self, **close_kwargs):
371+
"""Context manager to close the auth object on exit.
372+
373+
The behavior is somewhat similar to `contextlib.closing(self)`, but has
374+
some differences.
375+
376+
The context manager cannot be entered if the auth object is closed.
377+
378+
If a non-`Exception` (e.g. `KeyboardInterrupt`) is caught from the
379+
block, this context manager prioritizes re-raising the exception as
380+
fast as possible, without blocking. Thus, in this case, the tokens will
381+
not be revoked, even if `revoke=True` was passed to this method.
382+
383+
If exceptions are raised both from the block and from `close()`, the
384+
exception from the block will be reraised, and the exception from
385+
`close()` will be swallowed. The assumption is that the exception from
386+
the block is more relevant to the client, especially since the revoke
387+
can fail if the network is unavailable.
388+
389+
:param **close_kwargs: Keyword arguments to pass to `close()`.
390+
"""
391+
self._check_closed()
392+
exc_infos = []
393+
394+
# pylint:disable=broad-except
395+
try:
396+
yield self
397+
except Exception:
398+
exc_infos.append(sys.exc_info())
399+
except BaseException:
400+
exc_infos.append(sys.exc_info())
401+
close_kwargs['revoke'] = False
402+
403+
try:
404+
self.close(**close_kwargs)
405+
except Exception:
406+
exc_infos.append(sys.exc_info())
407+
408+
if exc_infos:
409+
six.reraise(*exc_infos[0])
410+
411+
def _check_closed(self):
412+
if self.closed:
413+
raise ValueError("operation on a closed auth object")

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

pytest.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[pytest]
2+
addopts = --strict --showlocals -r a --tb=long
3+
xfail_strict = True
4+
junit_suite_name = boxsdk
5+
testpaths = test/

test/unit/auth/test_oauth2.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from threading import Thread
88
import uuid
99

10-
from mock import Mock
10+
from mock import Mock, patch
1111
import pytest
1212
from six.moves import range # pylint:disable=redefined-builtin
1313
from six.moves.urllib import parse as urlparse # pylint:disable=import-error,no-name-in-module,wrong-import-order
@@ -18,6 +18,14 @@
1818
from boxsdk.config import API
1919

2020

21+
class MyError(Exception):
22+
pass
23+
24+
25+
class MyBaseException(BaseException):
26+
pass
27+
28+
2129
@pytest.fixture(params=('https://url.com/foo?bar=baz', 'https://ȕŕľ.com/ƒőő?Ƅȁŕ=Ƅȁż', None))
2230
def redirect_url(request):
2331
"""A value for the `redirect_uri` query string parameter for OAuth2."""
@@ -354,3 +362,94 @@ def _get_tokens(self):
354362

355363
assert oauth.refresh(access_token) == new_tokens
356364
assert oauth.access_token == new_access_token
365+
366+
367+
def test_closed_is_false_after_init(client_id, client_secret, mock_network_layer):
368+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
369+
assert auth.closed is False
370+
371+
372+
def test_closed_is_true_after_close(client_id, client_secret, mock_network_layer):
373+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
374+
auth.close()
375+
assert auth.closed is True
376+
377+
378+
def test_token_requests_fail_after_close(client_id, client_secret, mock_network_layer):
379+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
380+
auth.close()
381+
with pytest.raises(ValueError):
382+
auth.refresh(auth.access_token)
383+
384+
385+
@pytest.mark.parametrize('raise_exception', [False, True])
386+
def test_context_manager_closes_auth_object(client_id, client_secret, mock_network_layer, raise_exception):
387+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
388+
try:
389+
with auth.closing():
390+
if raise_exception:
391+
raise MyError
392+
except MyError:
393+
pass
394+
assert auth.closed is True
395+
396+
397+
def test_context_manager_fails_after_close(client_id, client_secret, mock_network_layer):
398+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
399+
with auth.closing():
400+
pass
401+
with pytest.raises(ValueError):
402+
with auth.closing():
403+
assert False
404+
405+
406+
@pytest.mark.parametrize(('close_args', 'close_kwargs'), [((), {}), ((True,), {}), ((), dict(revoke=True))])
407+
def test_revoke_on_close(client_id, client_secret, access_token, mock_network_layer, close_args, close_kwargs):
408+
auth = OAuth2(client_id=client_id, client_secret=client_secret, access_token=access_token, network_layer=mock_network_layer)
409+
with patch.object(auth, 'revoke') as mock_revoke:
410+
auth.close(*close_args, **close_kwargs)
411+
mock_revoke.assert_called_once_with()
412+
413+
414+
def test_auth_object_is_closed_even_if_revoke_fails(client_id, client_secret, access_token, mock_network_layer):
415+
auth = OAuth2(client_id=client_id, client_secret=client_secret, access_token=access_token, network_layer=mock_network_layer)
416+
with patch.object(auth, 'revoke', side_effect=BoxOAuthException(status=500)):
417+
with pytest.raises(BoxOAuthException):
418+
auth.close(revoke=True)
419+
assert auth.closed is True
420+
421+
422+
@pytest.mark.parametrize(('close_args', 'close_kwargs'), [((False,), {}), ((), dict(revoke=False))])
423+
def test_revoke_on_close_can_be_skipped(client_id, client_secret, access_token, mock_network_layer, close_args, close_kwargs):
424+
auth = OAuth2(client_id=client_id, client_secret=client_secret, access_token=access_token, network_layer=mock_network_layer)
425+
with patch.object(auth, 'revoke') as mock_revoke:
426+
auth.close(*close_args, **close_kwargs)
427+
mock_revoke.assert_not_called()
428+
429+
430+
@pytest.mark.parametrize(('raise_from_block', 'raise_from_close', 'expected_exception'), [
431+
(MyError, None, MyError),
432+
(None, BoxOAuthException(status=500), BoxOAuthException),
433+
(MyError, BoxOAuthException(status=500), MyError),
434+
])
435+
@pytest.mark.parametrize('close_kwargs', [{}, dict(revoke=False), dict(revoke=True)])
436+
def test_context_manager_reraises_first_exception_after_close(
437+
client_id, client_secret, mock_network_layer, close_kwargs, raise_from_block, raise_from_close, expected_exception,
438+
):
439+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
440+
with patch.object(auth, 'close', side_effect=raise_from_close) as mock_close:
441+
with pytest.raises(expected_exception):
442+
with auth.closing(**close_kwargs):
443+
if raise_from_block:
444+
raise raise_from_block
445+
mock_close.assert_called_once_with(**close_kwargs)
446+
447+
448+
@pytest.mark.parametrize('close_kwargs', [{}, dict(revoke=False), dict(revoke=True)])
449+
def test_context_manager_skips_revoke_on_base_exception(client_id, client_secret, mock_network_layer, close_kwargs):
450+
auth = OAuth2(client_id=client_id, client_secret=client_secret, network_layer=mock_network_layer)
451+
with patch.object(auth, 'close') as mock_close:
452+
with pytest.raises(MyBaseException):
453+
with auth.closing(**close_kwargs):
454+
raise MyBaseException
455+
mock_close.assert_called_once_with(revoke=False)

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ envlist =
2020

2121
[testenv]
2222
commands =
23-
py.test test/ {posargs}
23+
pytest {posargs}
2424
deps = -rrequirements-dev.txt
2525

2626
[testenv:rst]

0 commit comments

Comments
 (0)