Skip to content

Commit 481a862

Browse files
committed
Merge pull request #134 from box/issue_128
Always update `OAuth2` object with new tokens
2 parents 3ba6cf1 + 7239ba7 commit 481a862

File tree

10 files changed

+128
-43
lines changed

10 files changed

+128
-43
lines changed

HISTORY.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Release History
66
Upcoming
77
++++++++
88

9+
1.5.2
10+
++++++++++++++++++
11+
12+
- Bugfix so that ``OAuth2`` always has the correct tokens after a call to ``refresh()``.
13+
914
1.5.1 (2016-03-23)
1015
++++++++++++++++++
1116

boxsdk/auth/oauth2.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,17 @@ def _get_tokens(self):
167167
"""
168168
Get the current access and refresh tokens.
169169
170+
This is a protected method that can be overridden to look up tokens
171+
from an external source (the inverse of the `store_tokens` callback).
172+
173+
This method does not need to update this object's private token
174+
attributes. Its caller in :class:`OAuth2` is responsible for that.
175+
170176
:return:
171177
Tuple containing the current access token and refresh token.
178+
One or both of them may be `None`, if they aren't set.
172179
:rtype:
173-
`tuple` of (`unicode`, `unicode`)
180+
`tuple` of ((`unicode` or `None`), (`unicode` or `None`))
174181
"""
175182
return self._access_token, self._refresh_token
176183

@@ -181,16 +188,24 @@ def refresh(self, access_token_to_refresh):
181188
182189
:param access_token_to_refresh:
183190
The expired access token, which needs to be refreshed.
191+
Pass `None` if you don't have the access token.
184192
:type access_token_to_refresh:
185-
`unicode`
193+
`unicode` or `None`
194+
:return:
195+
Tuple containing the new access token and refresh token.
196+
The refresh token may be `None`, if the authentication scheme
197+
doesn't use one, or keeps it hidden from this client.
198+
:rtype:
199+
`tuple` of (`unicode`, (`unicode` or `None`))
186200
"""
187201
with self._refresh_lock:
188-
access_token, refresh_token = self._get_tokens()
202+
access_token, refresh_token = self._get_and_update_current_tokens()
189203
# The lock here is for handling that case that multiple requests fail, due to access token expired, at the
190204
# same time to avoid multiple session renewals.
191-
if access_token_to_refresh == access_token:
192-
# If the active access token is the same as the token needs to be refreshed, we make the request to
193-
# refresh the token.
205+
if (access_token is None) or (access_token_to_refresh == access_token):
206+
# If the active access token is the same as the token needs to
207+
# be refreshed, or if we don't currently have any active access
208+
# token, we make the request to refresh the token.
194209
return self._refresh(access_token_to_refresh)
195210
else:
196211
# If the active access token (self._access_token) is not the same as the token needs to be refreshed,
@@ -213,11 +228,37 @@ def _get_state_csrf_token():
213228
return 'box_csrf_token_' + ''.join(ascii_alphabet[int(system_random.random() * ascii_len)] for _ in range(16))
214229

215230
def _store_tokens(self, access_token, refresh_token):
216-
self._access_token = access_token
217-
self._refresh_token = refresh_token
231+
self._update_current_tokens(access_token, refresh_token)
218232
if self._store_tokens_callback is not None:
219233
self._store_tokens_callback(access_token, refresh_token)
220234

235+
def _get_and_update_current_tokens(self):
236+
"""Get the current access and refresh tokens, while also storing them in this object's private attributes.
237+
238+
:return:
239+
Same as for :meth:`_get_tokens()`.
240+
"""
241+
tokens = self._get_tokens()
242+
self._update_current_tokens(*tokens)
243+
return tokens
244+
245+
def _update_current_tokens(self, access_token, refresh_token):
246+
"""Store the latest tokens in this object's private attributes.
247+
248+
:param access_token:
249+
The latest access token.
250+
May be `None`, if it hasn't been provided.
251+
:type access_token:
252+
`unicode` or `None`
253+
:param refresh_token:
254+
The latest refresh token.
255+
May be `None`, if the authentication scheme doesn't use one, or if
256+
it hasn't been provided.
257+
:type refresh_token:
258+
`unicode` or `None`
259+
"""
260+
self._access_token, self._refresh_token = access_token, refresh_token
261+
221262
def send_token_request(self, data, access_token, expect_refresh_token=True):
222263
"""
223264
Send the request to acquire or refresh an access token.
@@ -262,7 +303,7 @@ def revoke(self):
262303
Revoke the authorization for the current access/refresh token pair.
263304
"""
264305
with self._refresh_lock:
265-
access_token, refresh_token = self._get_tokens()
306+
access_token, refresh_token = self._get_and_update_current_tokens()
266307
token_to_revoke = access_token or refresh_token
267308
if token_to_revoke is None:
268309
return

boxsdk/auth/redis_managed_oauth2.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,7 @@ def __init__(self, unique_id=uuid4(), redis_server=None, *args, **kwargs):
3030
refresh_lock = Lock(redis=self._redis_server, name='{0}_lock'.format(self._unique_id))
3131
super(RedisManagedOAuth2Mixin, self).__init__(*args, refresh_lock=refresh_lock, **kwargs)
3232
if self._access_token is None:
33-
self._update_current_tokens()
34-
35-
def _update_current_tokens(self):
36-
"""
37-
Get the latest tokens from redis and store them.
38-
"""
39-
self._access_token, self._refresh_token = self._redis_server.hvals(self._unique_id) or (None, None)
33+
self._get_and_update_current_tokens()
4034

4135
@property
4236
def unique_id(self):
@@ -51,8 +45,7 @@ def _get_tokens(self):
5145
Base class override.
5246
Gets the latest tokens from redis before returning them.
5347
"""
54-
self._update_current_tokens()
55-
return super(RedisManagedOAuth2Mixin, self)._get_tokens()
48+
return self._redis_server.hvals(self._unique_id) or (None, None)
5649

5750
def _store_tokens(self, access_token, refresh_token):
5851
"""

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__ = '1.5.1'
6+
__version__ = '1.5.2'

requirements-dev.txt

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
11
-r requirements.txt
22
bottle
33
jsonpatch
4-
mock<=1.0.1
4+
mock>=2.0.0
55
pep8
66
pylint
7-
8-
# Temporary version exclusion of the 2.8 release line.
9-
# <https://github.com/pytest-dev/pytest/issues/1085> breaks pytest on Python 2, only in 2.8.1. Fixed in upcoming 2.8.2.
10-
# <https://github.com/pytest-dev/pytest/issues/1035> breaks pytest on Python 2.6, on all currently existing 2.8.*
11-
# releases. Has not yet been fixed in the master branch, so there isn't a guarantee that it will work in the upcoming
12-
# 2.8.2 release.
13-
pytest<2.8
14-
7+
pytest>=2.8.3
158
pytest-cov
169
pytest-xdist
1710
sphinx

test/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ def access_token():
102102
return 'T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl'
103103

104104

105+
@pytest.fixture(scope='session')
106+
def new_access_token():
107+
# Must be distinct from access_token.
108+
return 'ZFoWjFHvNbvVqHjlT9cE5asGnuyYCCqI'
109+
110+
105111
@pytest.fixture(scope='session')
106112
def refresh_token():
107113
return 'J7rxTiWOHMoSC1isKZKBZWizoRXjkQzig5C6jFgCVJ9bUnsUfGMinKBDLZWP9BgRb'

test/unit/auth/test_jwt_auth.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ def jwt_auth_init_mocks(
7373
}
7474

7575
mock_network_layer.request.return_value = successful_token_response
76-
key_file = mock_open()
77-
with patch('boxsdk.auth.jwt_auth.open', key_file, create=True) as jwt_auth_open:
76+
key_file_read_data = 'key_file_read_data'
77+
with patch('boxsdk.auth.jwt_auth.open', mock_open(read_data=key_file_read_data), create=True) as jwt_auth_open:
7878
with patch('cryptography.hazmat.primitives.serialization.load_pem_private_key') as load_pem_private_key:
7979
oauth = JWTAuth(
8080
client_id=fake_client_id,
@@ -89,9 +89,9 @@ def jwt_auth_init_mocks(
8989
)
9090

9191
jwt_auth_open.assert_called_once_with(sentinel.rsa_path)
92-
key_file.return_value.read.assert_called_once_with() # pylint:disable=no-member
92+
jwt_auth_open.return_value.read.assert_called_once_with() # pylint:disable=no-member
9393
load_pem_private_key.assert_called_once_with(
94-
key_file.return_value.read.return_value, # pylint:disable=no-member
94+
key_file_read_data,
9595
password=rsa_passphrase,
9696
backend=default_backend(),
9797
)

test/unit/auth/test_oauth2.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
from functools import partial
66
import re
77
from threading import Thread
8+
import uuid
89

910
from mock import Mock
1011
import pytest
12+
from six.moves import range # pylint:disable=redefined-builtin
1113
from six.moves.urllib import parse as urlparse # pylint:disable=import-error,no-name-in-module,wrong-import-order
1214

1315
from boxsdk.exception import BoxOAuthException
@@ -314,3 +316,41 @@ def test_revoke_sends_revoke_request(
314316
access_token=access_token,
315317
)
316318
assert oauth.access_token is None
319+
320+
321+
def test_tokens_get_updated_after_noop_refresh(client_id, client_secret, access_token, new_access_token, refresh_token, mock_network_layer):
322+
"""`OAuth2` object should update its state with new tokens, after no-op refresh.
323+
324+
If the protected method `_get_tokens()` returns new tokens, refresh is
325+
skipped, and those tokens are used.
326+
327+
This is a regression test for issue #128 [1]. We would return the new
328+
tokens without updating the object state. Subsequent uses of the `OAuth2`
329+
object would use the old tokens.
330+
331+
[1] <https://github.com/box/box-python-sdk/issues/128>
332+
"""
333+
new_refresh_token = uuid.uuid4().hex
334+
new_tokens = (new_access_token, new_refresh_token)
335+
336+
class GetTokensOAuth2(OAuth2):
337+
def _get_tokens(self):
338+
"""Return a new set of tokens, without updating any state.
339+
340+
In order for the test to pass, the `OAuth2` object must be
341+
correctly programmed to take this return value and use it to update
342+
its state.
343+
"""
344+
return new_tokens
345+
346+
oauth = GetTokensOAuth2(
347+
client_id=client_id,
348+
client_secret=client_secret,
349+
access_token=access_token,
350+
refresh_token=refresh_token,
351+
network_layer=mock_network_layer,
352+
)
353+
assert oauth.access_token == access_token
354+
355+
assert oauth.refresh(access_token) == new_tokens
356+
assert oauth.access_token == new_access_token

test/unit/auth/test_redis_managed_oauth2.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import unicode_literals, absolute_import
44

5+
import uuid
6+
57
from mock import Mock, patch
68

79
from boxsdk.auth import redis_managed_oauth2
@@ -21,19 +23,24 @@ def test_redis_managed_oauth2_gets_tokens_from_redis_on_init(access_token, refre
2123
assert oauth2.unique_id is unique_id
2224

2325

24-
def test_redis_managed_oauth2_gets_tokens_from_redis_during_refresh(access_token, refresh_token):
26+
def test_redis_managed_oauth2_gets_tokens_from_redis_during_refresh(access_token, refresh_token, new_access_token):
27+
new_refresh_token = uuid.uuid4().hex
2528
redis_server = Mock(redis_managed_oauth2.StrictRedis)
26-
redis_server.hvals.return_value = access_token, refresh_token
29+
redis_server.hvals.return_value = new_access_token, new_refresh_token
2730
unique_id = Mock()
28-
with patch.object(redis_managed_oauth2.RedisManagedOAuth2Mixin, '_update_current_tokens'):
29-
oauth2 = redis_managed_oauth2.RedisManagedOAuth2(
30-
client_id=None,
31-
client_secret=None,
32-
unique_id=unique_id,
33-
redis_server=redis_server,
34-
)
31+
oauth2 = redis_managed_oauth2.RedisManagedOAuth2(
32+
access_token=access_token,
33+
refresh_token=refresh_token,
34+
client_id=None,
35+
client_secret=None,
36+
unique_id=unique_id,
37+
redis_server=redis_server,
38+
)
39+
assert oauth2.access_token == access_token
40+
redis_server.hvals.assert_not_called()
3541

36-
assert oauth2.refresh('bogus_access_token') == (access_token, refresh_token)
42+
assert oauth2.refresh('bogus_access_token') == (new_access_token, new_refresh_token)
43+
assert oauth2.access_token == new_access_token
3744
redis_server.hvals.assert_called_once_with(unique_id)
3845

3946

test/unit/network/test_logging_network.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_logging_network_does_not_call_setup_logging_if_logger_is_not_none():
2828
logger = Mock(Logger)
2929
with patch.object(logging_network, 'setup_logging') as setup_logging:
3030
network = LoggingNetwork(logger)
31-
setup_logging.assert_never_called()
31+
setup_logging.assert_not_called()
3232
assert network.logger is logger
3333

3434

0 commit comments

Comments
 (0)