Skip to content

Commit e2014ed

Browse files
dzleidigyangchoo
andauthored
feat: Support Bearer Token Authentication (#165)
We will soon be passing a client_credentials (service-to-service) bearer token to programs when executing in the context of Igor. Web will validate the bearer token (and only the bearer token, no user token needed) when receiving an API request. Programs internally call the transcriptic library (this repo) to form an API Connection. The change in this PR involves changing the Connection instantiation routine to recognize if the token is a Bearer token and, if so, set the standard OAuth Authorization header (and no X-User-Token header). This is opposed to a non-bearer token, which will result in the usual X-User-Token header. Jira Issue: SEA-271 Co-authored-by: Yang <[email protected]>
1 parent b203459 commit e2014ed

File tree

6 files changed

+125
-3
lines changed

6 files changed

+125
-3
lines changed

.pre-commit-config.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ repos:
1717
args:
1818
- --disable=W,R,C
1919
- --rcfile=.pylintrc
20+
- --jobs=1
2021
name: Pylint
2122
language: system
2223
types: [python]

CHANGELOG.rst

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Changelog
22
=========
33

4+
Added
5+
~~~~~
6+
7+
- Support bearer token authentication
8+
49
Updated
510
~~~~~~~
611

test/config_test.py

+77
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import tempfile
44
import re
55

6+
import pytest
67
import requests
78
from email.utils import formatdate
89

@@ -170,3 +171,79 @@ def test_signing(self):
170171
set(re.search(r'headers="(.+?)"', post_sig).group(1).split(" ")),
171172
{"(request-target)", "date", "host", "content-length", "digest"},
172173
)
174+
175+
def test_bearer_token(self):
176+
"""Verify that the authorization header is set when a bearer token is provided"""
177+
178+
bearer_token = (
179+
"Bearer eyJraWQiOiJWcmVsOE9zZ0JXaUpHeEpMeFJ4bE1UaVwvbjgyc1hwWktUaTd2UExUNFQ0T"
180+
"T0iLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJoMTBlM2hwajliNjc4bXMwOG8zbGlibHQ2IiwidG9r"
181+
"ZW5fdXNlIjoiYWNjZXNzIiwic2NvcGUiOiJ3ZWJcL2dldCB3ZWJcL3Bvc3QiLCJhdXRoX3RpbWUi"
182+
"OjE1OTM3MjM1NDgsImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC51cy1lYXN0LTEuYW1hem9u"
183+
"YXdzLmNvbVwvdXMtZWFzdC0xX1d6aEZzTGlPRyIsImV4cCI6MTU5MzcyNzE0OCwiaWF0IjoxNTkz"
184+
"NzIzNTQ4LCJ2ZXJzaW9uIjoyLCJqdGkiOiI4Njk5ZDEwYy05Mjg4LTQ0YmEtODIxNi01OTJjZGU3"
185+
"MDBhY2MiLCJjbGllbnRfaWQiOiJoMTBlM2hwajliNjc4bXMwOG8zbGlibHQ2In0.YA_yiD-x6UuB"
186+
"MShprUbUKuB_DO6ogCtd5srfgpJA6Ve_qsf8n19nVMmFsZBy3GxzN92P1ZXiFY99FfNPohhQtaRR"
187+
"hpeUkir08hgJN2bEHCJ5Ym8r9mr9mlwSG6FoiedgLaUVGwJujD9c2rcA83NEo8ayTyfCynF2AZ2p"
188+
"MxLHvqOYtvscGMiMzIwlZfJV301iKUVgPODJM5lpJ4iKCpOy2ByCl2_KL1uxIxgMkglpB-i7kgJc"
189+
"-WmYoJFoN88D89ugnEoAxNfK14N4_RyEkrLNGape9kew79nUeR6fWbVFLiGDDu25_9z-7VB-GGGk"
190+
"7L_Hb7YgVJ5W2FwESnkDvV1T4Q"
191+
)
192+
193+
connection = transcriptic.Connection(
194+
195+
bearer_token=bearer_token,
196+
organization_id="transcriptic",
197+
api_root="http://foo:5555",
198+
user_id="ufoo2",
199+
)
200+
201+
get_request = requests.Request("GET", "http://foo:5555/get")
202+
prepared_get = connection.session.prepare_request(get_request)
203+
204+
authorization_header_value = prepared_get.headers["authorization"]
205+
self.assertEqual(bearer_token, authorization_header_value)
206+
207+
def test_malformed_bearer_token(self):
208+
"""Verify that an exception is thrown when a malformed JWT bearer token is provided"""
209+
210+
bearer_token = "Bearer myBigBadBearerToken"
211+
212+
with pytest.raises(ValueError, match="Malformed JWT Bearer Token"):
213+
transcriptic.Connection(
214+
215+
bearer_token=bearer_token,
216+
organization_id="transcriptic",
217+
api_root="http://foo:5555",
218+
user_id="ufoo2",
219+
)
220+
221+
def test_user_token_supersedes_bearer_token(self):
222+
"""Verify that the user token and bearer token are mutually exclusive and that
223+
user token supersedes bearer token"""
224+
225+
user_token = "userTokenFoo"
226+
with tempfile.NamedTemporaryFile() as config_file:
227+
with open(config_file.name, "w") as f:
228+
json.dump(
229+
{
230+
"email": "[email protected]",
231+
"token": user_token,
232+
"bearer_token": "bearerTokenBar",
233+
"organization_id": "transcriptic",
234+
"api_root": "http://foo:5555",
235+
"analytics": True,
236+
"user_id": "ufoo2",
237+
"feature_groups": [
238+
"can_submit_autoprotocol",
239+
"can_upload_packages",
240+
],
241+
},
242+
f,
243+
)
244+
connection = transcriptic.config.Connection.from_file(config_file.name)
245+
246+
get_request = requests.Request("GET", "http://foo:5555/get")
247+
prepared_get = connection.session.prepare_request(get_request)
248+
self.assertFalse("authorization" in prepared_get.headers)
249+
self.assertTrue("X-User-Email" in prepared_get.headers)

transcriptic/config.py

+28-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import zipfile
1212

1313
from . import routes
14-
from .signing import StrateosSign
14+
from .signing import StrateosSign, BearerAuth
15+
from .util import is_valid_jwt_token
1516
from .version import __version__
1617

1718
try:
@@ -111,6 +112,7 @@ def __init__(
111112
feature_groups=[],
112113
rsa_key=None,
113114
session=None,
115+
bearer_token=None,
114116
):
115117
# Initialize environment args used for computing routes
116118
self.env_args = dict()
@@ -122,6 +124,8 @@ def __init__(
122124
session = initialize_default_session()
123125
self.session = session
124126

127+
self._bearer_token = None
128+
125129
# Initialize RSA props
126130
self._rsa_key = None
127131
self._rsa_key_path = None
@@ -149,7 +153,15 @@ def __init__(
149153
)
150154
self.session.headers["Cookie"] = None
151155
self.email = email
152-
self.token = token
156+
if token is not None:
157+
self.token = token
158+
if bearer_token is not None:
159+
warnings.warn(
160+
"User token and bearer token authentication"
161+
"is mutually exclusive. Ignoring bearer token"
162+
)
163+
elif bearer_token is not None:
164+
self.bearer_token = bearer_token
153165
self.update_session_auth()
154166

155167
# Initialize feature groups
@@ -240,6 +252,17 @@ def email(self, value):
240252
self.update_headers(**{"X-User-Email": value})
241253
self.update_session_auth()
242254

255+
@property
256+
def bearer_token(self):
257+
return self._bearer_token
258+
259+
@bearer_token.setter
260+
def bearer_token(self, value):
261+
if is_valid_jwt_token(value):
262+
self._bearer_token = value
263+
else:
264+
raise ValueError("Malformed JWT Bearer Token")
265+
243266
@property
244267
def token(self):
245268
try:
@@ -331,6 +354,8 @@ def update_session_auth(self, use_signature=True):
331354
and "X-User-Email" in self.session.headers
332355
):
333356
self.session.auth = StrateosSign(self.email, self._rsa_secret)
357+
elif self.bearer_token:
358+
self.session.auth = BearerAuth(self.bearer_token)
334359
else:
335360
self.session.auth = None
336361

@@ -1081,7 +1106,7 @@ def get_route(self, method, **kwargs):
10811106
input_args.append(arg_dict[arg])
10821107
else:
10831108
raise Exception(
1084-
f"For route: {method}, argument {arg} needs " f"to be provided."
1109+
f"For route: {method}, argument {arg} needs to be provided."
10851110
)
10861111
return route_method( # pylint: disable=no-value-for-parameter
10871112
*tuple(input_args)

transcriptic/signing.py

+9
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,12 @@ def __call__(self, request):
4646
return self.body_auth(request)
4747

4848
return self.auth(request)
49+
50+
51+
class BearerAuth(AuthBase):
52+
def __init__(self, token):
53+
self.token = token
54+
55+
def __call__(self, r):
56+
r.headers["authorization"] = self.token
57+
return r

transcriptic/util.py

+5
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,8 @@ def makedirs(name, mode=None, exist_ok=False):
9292

9393
mode = mode if mode is not None else 0o777
9494
makedirs(name, mode, exist_ok)
95+
96+
97+
def is_valid_jwt_token(token: str):
98+
regex = r"Bearer ([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_\-\+\/=]*)"
99+
return re.fullmatch(regex, token) is not None

0 commit comments

Comments
 (0)