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

Commit cfbfd25

Browse files
author
Jon Wayne Parrott
authored
Add jwt.OnDemandCredentials (#142)
1 parent 31c0863 commit cfbfd25

4 files changed

Lines changed: 430 additions & 10 deletions

File tree

google/auth/jwt.py

Lines changed: 273 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@
4646
import datetime
4747
import json
4848

49+
import cachetools
50+
from six.moves import urllib
51+
4952
from google.auth import _helpers
5053
from google.auth import _service_account_info
5154
from google.auth import crypt
55+
from google.auth import exceptions
5256
import google.auth.credentials
5357

54-
5558
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
59+
_DEFAULT_MAX_CACHE_SIZE = 10
5660

5761

5862
def encode(signer, payload, header=None, key_id=None):
@@ -316,10 +320,10 @@ def __init__(self, signer, issuer, subject, audience,
316320
self._audience = audience
317321
self._token_lifetime = token_lifetime
318322

319-
if additional_claims is not None:
320-
self._additional_claims = additional_claims
321-
else:
322-
self._additional_claims = {}
323+
if additional_claims is None:
324+
additional_claims = {}
325+
326+
self._additional_claims = additional_claims
323327

324328
@classmethod
325329
def _from_signer_and_info(cls, signer, info, **kwargs):
@@ -343,8 +347,7 @@ def _from_signer_and_info(cls, signer, info, **kwargs):
343347

344348
@classmethod
345349
def from_service_account_info(cls, info, **kwargs):
346-
"""Creates a Credentials instance from a dictionary containing service
347-
account info in Google format.
350+
"""Creates an Credentials instance from a dictionary.
348351
349352
Args:
350353
info (Mapping[str, str]): The service account info in Google
@@ -487,3 +490,266 @@ def signer_email(self):
487490
@_helpers.copy_docstring(google.auth.credentials.Signing)
488491
def signer(self):
489492
return self._signer
493+
494+
495+
class OnDemandCredentials(
496+
google.auth.credentials.Signing,
497+
google.auth.credentials.Credentials):
498+
"""On-demand JWT credentials.
499+
500+
Like :class:`Credentials`, this class uses a JWT as the bearer token for
501+
authentication. However, this class does not require the audience at
502+
construction time. Instead, it will generate a new token on-demand for
503+
each request using the request URI as the audience. It caches tokens
504+
so that multiple requests to the same URI do not incur the overhead
505+
of generating a new token every time.
506+
507+
This behavior is especially useful for `gRPC`_ clients. A gRPC service may
508+
have multiple audience and gRPC clients may not know all of the audiences
509+
required for accessing a particular service. With these credentials,
510+
no knowledge of the audiences is required ahead of time.
511+
512+
.. _grpc: http://www.grpc.io/
513+
"""
514+
515+
def __init__(self, signer, issuer, subject,
516+
additional_claims=None,
517+
token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS,
518+
max_cache_size=_DEFAULT_MAX_CACHE_SIZE):
519+
"""
520+
Args:
521+
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
522+
issuer (str): The `iss` claim.
523+
subject (str): The `sub` claim.
524+
additional_claims (Mapping[str, str]): Any additional claims for
525+
the JWT payload.
526+
token_lifetime (int): The amount of time in seconds for
527+
which the token is valid. Defaults to 1 hour.
528+
max_cache_size (int): The maximum number of JWT tokens to keep in
529+
cache. Tokens are cached using :class:`cachetools.LRUCache`.
530+
"""
531+
super(OnDemandCredentials, self).__init__()
532+
self._signer = signer
533+
self._issuer = issuer
534+
self._subject = subject
535+
self._token_lifetime = token_lifetime
536+
537+
if additional_claims is None:
538+
additional_claims = {}
539+
540+
self._additional_claims = additional_claims
541+
self._cache = cachetools.LRUCache(maxsize=max_cache_size)
542+
543+
@classmethod
544+
def _from_signer_and_info(cls, signer, info, **kwargs):
545+
"""Creates an OnDemandCredentials instance from a signer and service
546+
account info.
547+
548+
Args:
549+
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
550+
info (Mapping[str, str]): The service account info.
551+
kwargs: Additional arguments to pass to the constructor.
552+
553+
Returns:
554+
google.auth.jwt.OnDemandCredentials: The constructed credentials.
555+
556+
Raises:
557+
ValueError: If the info is not in the expected format.
558+
"""
559+
kwargs.setdefault('subject', info['client_email'])
560+
kwargs.setdefault('issuer', info['client_email'])
561+
return cls(signer, **kwargs)
562+
563+
@classmethod
564+
def from_service_account_info(cls, info, **kwargs):
565+
"""Creates an OnDemandCredentials instance from a dictionary.
566+
567+
Args:
568+
info (Mapping[str, str]): The service account info in Google
569+
format.
570+
kwargs: Additional arguments to pass to the constructor.
571+
572+
Returns:
573+
google.auth.jwt.OnDemandCredentials: The constructed credentials.
574+
575+
Raises:
576+
ValueError: If the info is not in the expected format.
577+
"""
578+
signer = _service_account_info.from_dict(
579+
info, require=['client_email'])
580+
return cls._from_signer_and_info(signer, info, **kwargs)
581+
582+
@classmethod
583+
def from_service_account_file(cls, filename, **kwargs):
584+
"""Creates an OnDemandCredentials instance from a service account .json
585+
file in Google format.
586+
587+
Args:
588+
filename (str): The path to the service account .json file.
589+
kwargs: Additional arguments to pass to the constructor.
590+
591+
Returns:
592+
google.auth.jwt.OnDemandCredentials: The constructed credentials.
593+
"""
594+
info, signer = _service_account_info.from_filename(
595+
filename, require=['client_email'])
596+
return cls._from_signer_and_info(signer, info, **kwargs)
597+
598+
@classmethod
599+
def from_signing_credentials(cls, credentials, **kwargs):
600+
"""Creates a new :class:`google.auth.jwt.OnDemandCredentials` instance
601+
from an existing :class:`google.auth.credentials.Signing` instance.
602+
603+
The new instance will use the same signer as the existing instance and
604+
will use the existing instance's signer email as the issuer and
605+
subject by default.
606+
607+
Example::
608+
609+
svc_creds = service_account.Credentials.from_service_account_file(
610+
'service_account.json')
611+
jwt_creds = jwt.OnDemandCredentials.from_signing_credentials(
612+
svc_creds)
613+
614+
Args:
615+
credentials (google.auth.credentials.Signing): The credentials to
616+
use to construct the new credentials.
617+
kwargs: Additional arguments to pass to the constructor.
618+
619+
Returns:
620+
google.auth.jwt.Credentials: A new Credentials instance.
621+
"""
622+
kwargs.setdefault('issuer', credentials.signer_email)
623+
kwargs.setdefault('subject', credentials.signer_email)
624+
return cls(credentials.signer, **kwargs)
625+
626+
def with_claims(self, issuer=None, subject=None, additional_claims=None):
627+
"""Returns a copy of these credentials with modified claims.
628+
629+
Args:
630+
issuer (str): The `iss` claim. If unspecified the current issuer
631+
claim will be used.
632+
subject (str): The `sub` claim. If unspecified the current subject
633+
claim will be used.
634+
additional_claims (Mapping[str, str]): Any additional claims for
635+
the JWT payload. This will be merged with the current
636+
additional claims.
637+
638+
Returns:
639+
google.auth.jwt.OnDemandCredentials: A new credentials instance.
640+
"""
641+
new_additional_claims = copy.deepcopy(self._additional_claims)
642+
new_additional_claims.update(additional_claims or {})
643+
644+
return OnDemandCredentials(
645+
self._signer,
646+
issuer=issuer if issuer is not None else self._issuer,
647+
subject=subject if subject is not None else self._subject,
648+
additional_claims=new_additional_claims,
649+
max_cache_size=self._cache.maxsize)
650+
651+
@property
652+
def valid(self):
653+
"""Checks the validity of the credentials.
654+
655+
These credentials are always valid because it generates tokens on
656+
demand.
657+
"""
658+
return True
659+
660+
def _make_jwt_for_audience(self, audience):
661+
"""Make a new JWT for the given audience.
662+
663+
Args:
664+
audience (str): The intended audience.
665+
666+
Returns:
667+
Tuple[bytes, datetime]: The encoded JWT and the expiration.
668+
"""
669+
now = _helpers.utcnow()
670+
lifetime = datetime.timedelta(seconds=self._token_lifetime)
671+
expiry = now + lifetime
672+
673+
payload = {
674+
'iss': self._issuer,
675+
'sub': self._subject,
676+
'iat': _helpers.datetime_to_secs(now),
677+
'exp': _helpers.datetime_to_secs(expiry),
678+
'aud': audience,
679+
}
680+
681+
payload.update(self._additional_claims)
682+
683+
jwt = encode(self._signer, payload)
684+
685+
return jwt, expiry
686+
687+
def _get_jwt_for_audience(self, audience):
688+
"""Get a JWT For a given audience.
689+
690+
If there is already an existing, non-expired token in the cache for
691+
the audience, that token is used. Otherwise, a new token will be
692+
created.
693+
694+
Args:
695+
audience (str): The intended audience.
696+
697+
Returns:
698+
bytes: The encoded JWT.
699+
"""
700+
token, expiry = self._cache.get(audience, (None, None))
701+
702+
if token is None or expiry < _helpers.utcnow():
703+
token, expiry = self._make_jwt_for_audience(audience)
704+
self._cache[audience] = token, expiry
705+
706+
return token
707+
708+
def refresh(self, request):
709+
"""Raises an exception, these credentials can not be directly
710+
refreshed.
711+
712+
Args:
713+
request (Any): Unused.
714+
715+
Raises:
716+
google.auth.RefreshError
717+
"""
718+
# pylint: disable=unused-argument
719+
# (pylint doesn't correctly recognize overridden methods.)
720+
raise exceptions.RefreshError(
721+
'OnDemandCredentials can not be directly refreshed.')
722+
723+
def before_request(self, request, method, url, headers):
724+
"""Performs credential-specific before request logic.
725+
726+
Args:
727+
request (Any): Unused. JWT credentials do not need to make an
728+
HTTP request to refresh.
729+
method (str): The request's HTTP method.
730+
url (str): The request's URI. This is used as the audience claim
731+
when generating the JWT.
732+
headers (Mapping): The request's headers.
733+
"""
734+
# pylint: disable=unused-argument
735+
# (pylint doesn't correctly recognize overridden methods.)
736+
parts = urllib.parse.urlsplit(url)
737+
# Strip query string and fragment
738+
audience = urllib.parse.urlunsplit(
739+
(parts.scheme, parts.netloc, parts.path, None, None))
740+
token = self._get_jwt_for_audience(audience)
741+
self.apply(headers, token=token)
742+
743+
@_helpers.copy_docstring(google.auth.credentials.Signing)
744+
def sign_bytes(self, message):
745+
return self._signer.sign(message)
746+
747+
@property
748+
@_helpers.copy_docstring(google.auth.credentials.Signing)
749+
def signer_email(self):
750+
return self._issuer
751+
752+
@property
753+
@_helpers.copy_docstring(google.auth.credentials.Signing)
754+
def signer(self):
755+
return self._signer

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
'pyasn1-modules>=0.0.5',
2424
'rsa>=3.1.4',
2525
'six>=1.9.0',
26+
'cachetools>=2.0.0',
2627
)
2728

2829

system_tests/test_grpc.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_grpc_request_with_regular_credentials(http_request):
3939
list(list_topics_iter)
4040

4141

42-
def test_grpc_request_with_jwt_credentials(http_request):
42+
def test_grpc_request_with_jwt_credentials():
4343
credentials, project_id = google.auth.default()
4444
audience = 'https://{}/google.pubsub.v1.Publisher'.format(
4545
publisher_client.PublisherClient.SERVICE_ADDRESS)
@@ -49,7 +49,27 @@ def test_grpc_request_with_jwt_credentials(http_request):
4949

5050
channel = google.auth.transport.grpc.secure_authorized_channel(
5151
credentials,
52-
http_request,
52+
None,
53+
publisher_client.PublisherClient.SERVICE_ADDRESS)
54+
55+
# Create a pub/sub client.
56+
client = publisher_client.PublisherClient(channel=channel)
57+
58+
# list the topics and drain the iterator to test that an authorized API
59+
# call works.
60+
list_topics_iter = client.list_topics(
61+
project='projects/{}'.format(project_id))
62+
list(list_topics_iter)
63+
64+
65+
def test_grpc_request_with_on_demand_jwt_credentials():
66+
credentials, project_id = google.auth.default()
67+
credentials = google.auth.jwt.OnDemandCredentials.from_signing_credentials(
68+
credentials)
69+
70+
channel = google.auth.transport.grpc.secure_authorized_channel(
71+
credentials,
72+
None,
5373
publisher_client.PublisherClient.SERVICE_ADDRESS)
5474

5575
# Create a pub/sub client.

0 commit comments

Comments
 (0)