Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit ab08689

Browse files
author
Jon Wayne Parrott
authored
Remove one-time token behavior of JWT Credentials (#117)
1 parent 254befe commit ab08689

5 files changed

Lines changed: 58 additions & 108 deletions

File tree

google/auth/jwt.py

Lines changed: 19 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,6 @@
4545
import datetime
4646
import json
4747

48-
from six.moves import urllib
49-
5048
from google.auth import _helpers
5149
from google.auth import _service_account_info
5250
from google.auth import credentials
@@ -246,11 +244,7 @@ class Credentials(credentials.Signing,
246244
"""Credentials that use a JWT as the bearer token.
247245
248246
These credentials require an "audience" claim. This claim identifies the
249-
intended recipient of the bearer token. You can set the audience when
250-
you construct these credentials, however, these credentials can also set
251-
the audience claim automatically if not specified. In this case, whenever
252-
a request is made the credentials will automatically generate a one-time
253-
JWT with the request URI as the audience.
247+
intended recipient of the bearer token.
254248
255249
The constructor arguments determine the claims for the JWT that is
256250
sent with requests. Usually, you'll construct these credentials with
@@ -260,13 +254,15 @@ class Credentials(credentials.Signing,
260254
JSON file::
261255
262256
credentials = jwt.Credentials.from_service_account_file(
263-
'service-account.json')
257+
'service-account.json',
258+
audience='https://speech.googleapis.com')
264259
265260
If you already have the service account file loaded and parsed::
266261
267262
service_account_info = json.load(open('service_account.json'))
268263
credentials = jwt.Credentials.from_service_account_info(
269-
service_account_info)
264+
service_account_info,
265+
audience='https://speech.googleapis.com')
270266
271267
Both helper methods pass on arguments to the constructor, so you can
272268
specify the JWT claims::
@@ -280,7 +276,10 @@ class Credentials(credentials.Signing,
280276
:class:`~google.auth.crypt.Signer` instance::
281277
282278
credentials = jwt.Credentials(
283-
signer, issuer='your-issuer', subject='your-subject')
279+
signer,
280+
issuer='your-issuer',
281+
subject='your-subject',
282+
audience=''https://speech.googleapis.com'')
284283
285284
The claims are considered immutable. If you want to modify the claims,
286285
you can easily create another instance using :meth:`with_claims`::
@@ -289,7 +288,7 @@ class Credentials(credentials.Signing,
289288
audience='https://vision.googleapis.com')
290289
"""
291290

292-
def __init__(self, signer, issuer=None, subject=None, audience=None,
291+
def __init__(self, signer, issuer, subject, audience,
293292
additional_claims=None,
294293
token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS):
295294
"""
@@ -298,8 +297,7 @@ def __init__(self, signer, issuer=None, subject=None, audience=None,
298297
issuer (str): The `iss` claim.
299298
subject (str): The `sub` claim.
300299
audience (str): the `aud` claim. The intended audience for the
301-
credentials. If not specified, a new JWT will be generated for
302-
every request and will use the request URI as the audience.
300+
credentials.
303301
additional_claims (Mapping[str, str]): Any additional claims for
304302
the JWT payload.
305303
token_lifetime (int): The amount of time in seconds for
@@ -334,7 +332,8 @@ def _from_signer_and_info(cls, signer, info, **kwargs):
334332
ValueError: If the info is not in the expected format.
335333
"""
336334
kwargs.setdefault('subject', info['client_email'])
337-
return cls(signer, issuer=info['client_email'], **kwargs)
335+
kwargs.setdefault('issuer', info['client_email'])
336+
return cls(signer, **kwargs)
338337

339338
@classmethod
340339
def from_service_account_info(cls, info, **kwargs):
@@ -381,9 +380,8 @@ def with_claims(self, issuer=None, subject=None, audience=None,
381380
claim will be used.
382381
subject (str): The `sub` claim. If unspecified the current subject
383382
claim will be used.
384-
audience (str): the `aud` claim. If not specified, a new
385-
JWT will be generated for every request and will use
386-
the request URI as the audience.
383+
audience (str): the `aud` claim. If unspecified the current
384+
audience claim will be used.
387385
additional_claims (Mapping[str, str]): Any additional claims for
388386
the JWT payload. This will be merged with the current
389387
additional claims.
@@ -399,12 +397,9 @@ def with_claims(self, issuer=None, subject=None, audience=None,
399397
additional_claims=self._additional_claims.copy().update(
400398
additional_claims or {}))
401399

402-
def _make_jwt(self, audience=None):
400+
def _make_jwt(self):
403401
"""Make a signed JWT.
404402
405-
Args:
406-
audience (str): Overrides the instance's current audience claim.
407-
408403
Returns:
409404
Tuple[bytes, datetime]: The encoded JWT and the expiration.
410405
"""
@@ -414,10 +409,10 @@ def _make_jwt(self, audience=None):
414409

415410
payload = {
416411
'iss': self._issuer,
417-
'sub': self._subject or self._issuer,
412+
'sub': self._subject,
418413
'iat': _helpers.datetime_to_secs(now),
419414
'exp': _helpers.datetime_to_secs(expiry),
420-
'aud': audience or self._audience,
415+
'aud': self._audience,
421416
}
422417

423418
payload.update(self._additional_claims)
@@ -426,22 +421,6 @@ def _make_jwt(self, audience=None):
426421

427422
return jwt, expiry
428423

429-
def _make_one_time_jwt(self, uri):
430-
"""Makes a one-off JWT with the URI as the audience.
431-
432-
Args:
433-
uri (str): The request URI.
434-
435-
Returns:
436-
bytes: The encoded JWT.
437-
"""
438-
parts = urllib.parse.urlsplit(uri)
439-
# Strip query string and fragment
440-
audience = urllib.parse.urlunsplit(
441-
(parts.scheme, parts.netloc, parts.path, None, None))
442-
token, _ = self._make_jwt(audience=audience)
443-
return token
444-
445424
def refresh(self, request):
446425
"""Refreshes the access token.
447426
@@ -452,15 +431,8 @@ def refresh(self, request):
452431
# (pylint doesn't correctly recognize overridden methods.)
453432
self.token, self.expiry = self._make_jwt()
454433

434+
@_helpers.copy_docstring(credentials.Signing)
455435
def sign_bytes(self, message):
456-
"""Signs the given message.
457-
458-
Args:
459-
message (bytes): The message to sign.
460-
461-
Returns:
462-
bytes: The message signature.
463-
"""
464436
return self._signer.sign(message)
465437

466438
@property
@@ -472,32 +444,3 @@ def signer_email(self):
472444
@_helpers.copy_docstring(credentials.Signing)
473445
def signer(self):
474446
return self._signer
475-
476-
def before_request(self, request, method, url, headers):
477-
"""Performs credential-specific before request logic.
478-
479-
If an audience is specified it will refresh the credentials if
480-
necessary. If no audience is specified it will generate a one-time
481-
token for the request URI. In either case, it will set the
482-
authorization header in headers to the token.
483-
484-
Args:
485-
request (Any): Unused.
486-
method (str): The request's HTTP method.
487-
url (str): The request's URI.
488-
headers (Mapping): The request's headers.
489-
"""
490-
# pylint: disable=unused-argument
491-
# (pylint doesn't correctly recognize overridden methods.)
492-
493-
# If this set of credentials has a pre-set audience, just ensure that
494-
# there is a valid token and apply the auth headers.
495-
if self._audience:
496-
if not self.valid:
497-
self.refresh(request)
498-
self.apply(headers)
499-
# Otherwise, generate a one-time token using the URL
500-
# (without the query string and fragment) as the audience.
501-
else:
502-
token = self._make_one_time_jwt(url)
503-
self.apply(headers, token=token)

google/oauth2/service_account.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def from_service_account_file(cls, filename, **kwargs):
204204
filename, require=['client_email', 'token_uri'])
205205
return cls._from_signer_and_info(signer, info, **kwargs)
206206

207-
def to_jwt_credentials(self):
207+
def to_jwt_credentials(self, audience):
208208
"""Creates a :class:`google.auth.jwt.Credentials` instance from this
209209
instance.
210210
@@ -223,13 +223,18 @@ def to_jwt_credentials(self):
223223
jwt_creds = jwt.Credentials.from_service_account_file(
224224
'service_account.json')
225225
226+
Args:
227+
audience (str): the `aud` claim. The intended audience for the
228+
credentials.
229+
226230
Returns:
227231
google.auth.jwt.Credentials: A new Credentials instance.
228232
"""
229233
return jwt.Credentials(
230234
self._signer,
231235
issuer=self._service_account_email,
232-
subject=self._service_account_email)
236+
subject=self._service_account_email,
237+
audience=audience)
233238

234239
@property
235240
def service_account_email(self):

system_tests/test_grpc.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ def test_grpc_request_with_regular_credentials(http_request):
4040

4141
def test_grpc_request_with_jwt_credentials(http_request):
4242
credentials, project_id = google.auth.default()
43-
credentials = credentials.to_jwt_credentials()
43+
audience = 'https://{}/google.pubsub.v1.Publisher'.format(
44+
publisher_client.PublisherClient.SERVICE_ADDRESS)
45+
credentials = credentials.to_jwt_credentials(
46+
audience=audience)
4447

4548
channel = google.auth.transport.grpc.secure_authorized_channel(
4649
credentials,

tests/oauth2/test_service_account.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ def test_from_service_account_file_args(self):
112112
assert credentials._additional_claims == additional_claims
113113

114114
def test_to_jwt_credentials(self):
115-
jwt_from_svc = self.credentials.to_jwt_credentials()
115+
jwt_from_svc = self.credentials.to_jwt_credentials(
116+
audience=mock.sentinel.audience)
116117
jwt_from_info = jwt.Credentials.from_service_account_info(
117-
SERVICE_ACCOUNT_INFO)
118+
SERVICE_ACCOUNT_INFO,
119+
audience=mock.sentinel.audience)
118120

119121
assert isinstance(jwt_from_svc, jwt.Credentials)
120122
assert jwt_from_svc._signer.key_id == jwt_from_info._signer.key_id

tests/test_jwt.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -206,17 +206,20 @@ class TestCredentials:
206206
@pytest.fixture(autouse=True)
207207
def credentials_fixture(self, signer):
208208
self.credentials = jwt.Credentials(
209-
signer, self.SERVICE_ACCOUNT_EMAIL)
209+
signer, self.SERVICE_ACCOUNT_EMAIL, self.SERVICE_ACCOUNT_EMAIL,
210+
self.AUDIENCE)
210211

211212
def test_from_service_account_info(self):
212213
with open(SERVICE_ACCOUNT_JSON_FILE, 'r') as fh:
213214
info = json.load(fh)
214215

215-
credentials = jwt.Credentials.from_service_account_info(info)
216+
credentials = jwt.Credentials.from_service_account_info(
217+
info, audience=self.AUDIENCE)
216218

217219
assert credentials._signer.key_id == info['private_key_id']
218220
assert credentials._issuer == info['client_email']
219221
assert credentials._subject == info['client_email']
222+
assert credentials._audience == self.AUDIENCE
220223

221224
def test_from_service_account_info_args(self):
222225
info = SERVICE_ACCOUNT_INFO.copy()
@@ -235,11 +238,12 @@ def test_from_service_account_file(self):
235238
info = SERVICE_ACCOUNT_INFO.copy()
236239

237240
credentials = jwt.Credentials.from_service_account_file(
238-
SERVICE_ACCOUNT_JSON_FILE)
241+
SERVICE_ACCOUNT_JSON_FILE, audience=self.AUDIENCE)
239242

240243
assert credentials._signer.key_id == info['private_key_id']
241244
assert credentials._issuer == info['client_email']
242245
assert credentials._subject == info['client_email']
246+
assert credentials._audience == self.AUDIENCE
243247

244248
def test_from_service_account_file_args(self):
245249
info = SERVICE_ACCOUNT_INFO.copy()
@@ -259,6 +263,18 @@ def test_default_state(self):
259263
# Expiration hasn't been set yet
260264
assert not self.credentials.expired
261265

266+
def test_with_claims(self):
267+
new_audience = 'new_audience'
268+
new_credentials = self.credentials.with_claims(
269+
audience=new_audience)
270+
271+
assert new_credentials._signer == self.credentials._signer
272+
assert new_credentials._issuer == self.credentials._issuer
273+
assert new_credentials._subject == self.credentials._subject
274+
assert new_credentials._audience == new_audience
275+
assert (new_credentials._additional_claims ==
276+
self.credentials._additional_claims)
277+
262278
def test_sign_bytes(self):
263279
to_sign = b'123'
264280
signature = self.credentials.sign_bytes(to_sign)
@@ -292,43 +308,24 @@ def test_expired(self):
292308
now.return_value = self.credentials.expiry + one_day
293309
assert self.credentials.expired
294310

295-
def test_before_request_one_time_token(self):
311+
def test_before_request(self):
296312
headers = {}
297313

298314
self.credentials.refresh(None)
299315
self.credentials.before_request(
300-
mock.Mock(), 'GET', 'http://example.com?a=1#3', headers)
301-
302-
header_value = headers['authorization']
303-
_, token = header_value.split(' ')
304-
305-
# This should be a one-off token, so it shouldn't be the same as the
306-
# credentials' stored token.
307-
assert token != self.credentials.token
308-
309-
payload = self._verify_token(token)
310-
assert payload['aud'] == 'http://example.com'
311-
312-
def test_before_request_with_preset_audience(self):
313-
headers = {}
314-
315-
credentials = self.credentials.with_claims(audience=self.AUDIENCE)
316-
credentials.refresh(None)
317-
credentials.before_request(
318316
None, 'GET', 'http://example.com?a=1#3', headers)
319317

320318
header_value = headers['authorization']
321319
_, token = header_value.split(' ')
322320

323321
# Since the audience is set, it should use the existing token.
324-
assert token.encode('utf-8') == credentials.token
322+
assert token.encode('utf-8') == self.credentials.token
325323

326324
payload = self._verify_token(token)
327325
assert payload['aud'] == self.AUDIENCE
328326

329327
def test_before_request_refreshes(self):
330-
credentials = self.credentials.with_claims(audience=self.AUDIENCE)
331-
assert not credentials.valid
332-
credentials.before_request(
328+
assert not self.credentials.valid
329+
self.credentials.before_request(
333330
None, 'GET', 'http://example.com?a=1#3', {})
334-
assert credentials.valid
331+
assert self.credentials.valid

0 commit comments

Comments
 (0)