Skip to content

Commit a6f7228

Browse files
authored
Merge pull request #12 from NYPL/noref-improve-oauth2-api-client
Fix Oauth2ApiClient token refresh, responses
2 parents 2d103b2 + d7a4f81 commit a6f7228

4 files changed

Lines changed: 107 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## v0.0.9 - 3/16/23
4+
- Improve Oauth2ApiClient token refresh and method responses
5+
36
## v0.0.8 - 3/3/23
47
- Pass in all kwargs from PostgreSQLClient to ConnectionPool so that all ConnectionPool settings
58
can be set from the wrapper

src/nypl_py_utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from .classes.kinesis_client import KinesisClient, KinesisClientError # noqa
33
from .classes.kms_client import KmsClient, KmsClientError # noqa
44
from .classes.mysql_client import MySQLClient, MySQLClientError # noqa
5-
from .classes.oauth2_api_client import Oauth2ApiClient # noqa
5+
from .classes.oauth2_api_client import Oauth2ApiClient, Oauth2ApiClientError # noqa
66
from .classes.postgresql_client import PostgreSQLClient, PostgreSQLClientError # noqa
77
from .classes.redshift_client import RedshiftClient, RedshiftClientError # noqa
88
from .classes.s3_client import S3Client, S3ClientError # noqa

src/nypl_py_utils/classes/oauth2_api_client.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ def __init__(self, client_id=None, client_secret=None, base_url=None,
2121
self.base_url = base_url \
2222
or os.environ.get('NYPL_API_BASE_URL', None)
2323

24-
self.client = None
25-
self.token = None
24+
self.oauth_client = None
2625

2726
self.logger = create_log('oauth2_api_client')
2827

@@ -56,39 +55,52 @@ def _do_http_method(self, method, request_path, **kwargs):
5655
"""
5756
Issue an HTTP method call on on the given request_path
5857
"""
59-
if not self.client:
58+
if not self.oauth_client:
6059
self._create_oauth_client()
6160

6261
url = f'{self.base_url}/{request_path}'
6362
self.logger.debug(f'{method} {url}')
6463

6564
try:
66-
return self.oauth_client.request(method, url, **kwargs).json()
67-
except TokenExpiredError as e:
68-
self.logger.debug(f'TokenExpiredError encountered: {e}')
69-
self._generate_access_token()
65+
# Build kwargs cleaned of local variables:
66+
kwargs_cleaned = {k: kwargs[k] for k in kwargs
67+
if not k.startswith('_do_http_method_')}
68+
resp = self.oauth_client.request(method, url, **kwargs_cleaned)
69+
resp.raise_for_status()
70+
return resp
71+
except TokenExpiredError:
72+
self.logger.debug('TokenExpiredError encountered')
73+
74+
# Raise error after 3 successive token refreshes
75+
kwargs['_do_http_method_token_refreshes'] = \
76+
kwargs.get('_do_http_method_token_refreshes', 0) + 1
77+
if kwargs['_do_http_method_token_refreshes'] > 3:
78+
raise Oauth2ApiClientError('Exhausted token refreshes') \
79+
from None
7080

81+
self._generate_access_token()
7182
return self._do_http_method(method, request_path, **kwargs)
72-
except TimeoutError as e:
73-
self.logger.error(f'TimeoutError encountered: {e}')
74-
return {}
7583

7684
def _create_oauth_client(self):
7785
"""
7886
Creates an authenticated a OAuth2Session instance for later requests
7987
"""
88+
client = BackendApplicationClient(client_id=self.client_id)
89+
self.oauth_client = OAuth2Session(client=client)
8090
self._generate_access_token()
81-
self.oauth_client = OAuth2Session(self.client_id, token=self.token)
8291

8392
def _generate_access_token(self):
8493
"""
8594
Fetch and store a fresh token
8695
"""
87-
client = BackendApplicationClient(client_id=self.client_id)
88-
oauth = OAuth2Session(client=client)
8996
self.logger.debug(f'Refreshing token via @{self.token_url}')
90-
self.token = oauth.fetch_token(
97+
self.oauth_client.fetch_token(
9198
token_url=self.token_url,
9299
client_id=self.client_id,
93100
client_secret=self.client_secret
94101
)
102+
103+
104+
class Oauth2ApiClientError(Exception):
105+
def __init__(self, message=None):
106+
self.message = message

tests/test_oauth2_api_client.py

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
11
import os
2+
import time
23
import json
34
import pytest
45
from requests_oauthlib import OAuth2Session
6+
from requests import HTTPError
57

6-
from nypl_py_utils import Oauth2ApiClient
7-
# from requests.exceptions import ConnectTimeout
8+
from nypl_py_utils import (Oauth2ApiClient, Oauth2ApiClientError)
89

910
_TOKEN_RESPONSE = {
1011
'access_token': 'super-secret-token',
11-
'expires_in': 3600,
12+
'expires_in': 1,
1213
'token_type': 'Bearer',
1314
'scope': ['offline_access', 'openid', 'login:staff', 'admin'],
14-
'id_token': 'super-secret-token',
15-
'expires_at': 1677599823.3180869
15+
'id_token': 'super-secret-token'
1616
}
1717

1818
BASE_URL = 'https://example.com/api/v0.1'
19+
TOKEN_URL = 'https://oauth.example.com/oauth/token'
1920

2021

2122
class TestOauth2ApiClient:
2223

2324
@pytest.fixture
24-
def test_instance(self, requests_mock):
25-
token_url = 'https://oauth.example.com/oauth/token'
26-
requests_mock.post(token_url, text=json.dumps(_TOKEN_RESPONSE))
25+
def token_server_post(self, requests_mock):
26+
token_url = TOKEN_URL
27+
token_response = dict(_TOKEN_RESPONSE)
28+
return requests_mock.post(token_url, text=json.dumps(token_response))
2729

30+
@pytest.fixture
31+
def test_instance(self, requests_mock):
2832
return Oauth2ApiClient(base_url=BASE_URL,
29-
token_url=token_url,
33+
token_url=TOKEN_URL,
3034
client_id='clientid',
3135
client_secret='clientsecret'
3236
)
@@ -50,42 +54,84 @@ def test_uses_env_vars(self):
5054
for key, value in env.items():
5155
os.environ[key] = ''
5256

53-
def test_generate_access_token(self, test_instance):
57+
def test_generate_access_token(self, test_instance, token_server_post):
58+
test_instance._create_oauth_client()
5459
test_instance._generate_access_token()
55-
assert test_instance.token['access_token']\
60+
assert test_instance.oauth_client.token['access_token']\
5661
== _TOKEN_RESPONSE['access_token']
5762

58-
def test_create_oauth_client(self, test_instance):
63+
def test_create_oauth_client(self, token_server_post, test_instance):
5964
test_instance._create_oauth_client()
6065
assert type(test_instance.oauth_client) is OAuth2Session
6166

62-
def test_do_http_method(self, requests_mock, test_instance):
67+
def test_do_http_method(self, requests_mock, token_server_post,
68+
test_instance):
69+
requests_mock.get(f'{BASE_URL}/foo', json={'foo': 'bar'})
70+
6371
requests_mock.get(f'{BASE_URL}/foo', json={'foo': 'bar'})
6472
resp = test_instance._do_http_method('GET', 'foo')
65-
assert resp == {'foo': 'bar'}
73+
assert resp.status_code == 200
74+
assert resp.json() == {'foo': 'bar'}
6675

67-
def test_token_expiration(self, requests_mock, test_instance):
76+
def test_token_expiration(self, requests_mock, test_instance,
77+
token_server_post, mocker):
6878
api_get_mock = requests_mock.get(f'{BASE_URL}/foo',
6979
json={'foo': 'bar'})
7080

71-
# Perform first request to auto-authenticate:
72-
resp = test_instance._do_http_method('GET', 'foo')
81+
# Perform first request:
82+
test_instance._do_http_method('GET', 'foo')
83+
# Expect this first call triggered a single token server call:
84+
assert len(token_server_post.request_history) == 1
85+
# And the GET request used the supplied Bearer token:
7386
assert api_get_mock.request_history[0]._request\
7487
.headers['Authorization'] == 'Bearer super-secret-token'
7588

76-
# Emulate token expiration:
77-
test_instance.token['expires_at'] = 0
78-
79-
token_post_mock = requests_mock.post(
80-
test_instance.token_url,
81-
text=json.dumps(_TOKEN_RESPONSE)
82-
)
89+
# The token obtained above expires in 1s, so wait out expiration:
90+
time.sleep(1.1)
91+
92+
# Register new token response:
93+
second_token_response = dict(_TOKEN_RESPONSE)
94+
second_token_response['id_token'] = 'super-secret-second-token'
95+
second_token_response['access_token'] = 'super-secret-second-token'
96+
second_token_server_post = requests_mock\
97+
.post(TOKEN_URL, text=json.dumps(second_token_response))
98+
99+
# Perform second request:
100+
test_instance._do_http_method('GET', 'foo')
101+
# Expect a call on the second token server:
102+
assert len(second_token_server_post.request_history) == 1
103+
# Expect the second GET request to carry the new Bearer token:
104+
assert api_get_mock.request_history[1]._request\
105+
.headers['Authorization'] == 'Bearer super-secret-second-token'
106+
107+
def test_error_status_raises_error(self, requests_mock, test_instance,
108+
token_server_post):
109+
requests_mock.get(f'{BASE_URL}/foo', status_code=400)
110+
111+
with pytest.raises(HTTPError):
112+
test_instance._do_http_method('GET', 'foo')
113+
114+
def test_token_refresh_failure_raises_error(
115+
self, requests_mock, test_instance, token_server_post):
116+
"""
117+
Failure to fetch a token can raise a number of errors including:
118+
- requests.exceptions.HTTPError for invalid access_token
119+
- oauthlib.oauth2.rfc6749.errors.InvalidClientError for invalid
120+
credentials
121+
- oauthlib.oauth2.rfc6749.errors.MissingTokenError for failure to
122+
fetch a token
123+
One error that can arise from this client itself is failure to fetch
124+
a new valid token in response to token expiration. This test asserts
125+
that the client will not allow more than successive 3 retries.
126+
"""
127+
requests_mock.get(f'{BASE_URL}/foo', json={'foo': 'bar'})
83128

84-
# Perform second request, which should detect token expiration and
85-
# re-authenticate:
86-
resp = test_instance._do_http_method('GET', 'foo')
129+
token_response = dict(_TOKEN_RESPONSE)
130+
token_response['expires_in'] = 0
131+
token_server_post = requests_mock\
132+
.post(TOKEN_URL, text=json.dumps(token_response))
87133

88-
assert token_post_mock.called is True
89-
assert api_get_mock.request_history[1]._request\
90-
.headers['Authorization'] == 'Bearer super-secret-token'
91-
assert resp == {'foo': 'bar'}
134+
with pytest.raises(Oauth2ApiClientError):
135+
test_instance._do_http_method('GET', 'foo')
136+
# Expect 1 initial token fetch, plus 3 retries:
137+
assert len(token_server_post.request_history) == 4

0 commit comments

Comments
 (0)