1616
1717from __future__ import annotations
1818
19+ import calendar
20+ import time
21+ from typing import Any
1922from typing import Optional
2023
2124import google .auth
2225from google .auth import exceptions as google_auth_exceptions
26+ from google .auth import jwt
2327from google .auth .transport .requests import Request
2428from google .oauth2 import service_account
2529import google .oauth2 .credentials
3337from .base_credential_exchanger import AuthCredentialMissingError
3438from .base_credential_exchanger import BaseAuthCredentialExchanger
3539
40+ _access_token_cache : dict [tuple [Any , ...], tuple [AuthCredential , float ]] = {}
41+ _id_token_cache : dict [tuple [Any , ...], tuple [AuthCredential , float ]] = {}
42+
43+
44+ def _get_cache_key (sa_config : ServiceAccount ) -> tuple [Any , ...]:
45+ scopes_tuple = tuple (sa_config .scopes ) if sa_config .scopes else ()
46+ if sa_config .use_default_credential :
47+ return (
48+ True ,
49+ scopes_tuple ,
50+ sa_config .use_id_token ,
51+ sa_config .audience ,
52+ )
53+ else :
54+ cred = sa_config .service_account_credential
55+ cred_id = cred .private_key_id if cred else None
56+ client_email = cred .client_email if cred else None
57+ return (
58+ False ,
59+ cred_id ,
60+ client_email ,
61+ scopes_tuple ,
62+ sa_config .use_id_token ,
63+ sa_config .audience ,
64+ )
65+
66+
67+ def _reset_cache ():
68+ global _access_token_cache , _id_token_cache
69+ _access_token_cache .clear ()
70+ _id_token_cache .clear ()
71+
3672
3773class ServiceAccountCredentialExchanger (BaseAuthCredentialExchanger ):
3874 """Fetches credentials for Google Service Account.
@@ -95,6 +131,13 @@ def _exchange_for_id_token(self, sa_config: ServiceAccount) -> AuthCredential:
95131 Raises:
96132 AuthCredentialMissingError: If token exchange fails.
97133 """
134+ cache_key = _get_cache_key (sa_config )
135+ cached_val = _id_token_cache .get (cache_key )
136+ if cached_val :
137+ token , expires_at = cached_val
138+ if time .time () < expires_at - 300 :
139+ return token
140+
98141 # audience and credential presence are validated by the ServiceAccount
99142 # model_validator at construction time.
100143 try :
@@ -103,6 +146,11 @@ def _exchange_for_id_token(self, sa_config: ServiceAccount) -> AuthCredential:
103146
104147 request = Request ()
105148 token = oauth2_id_token .fetch_id_token (request , sa_config .audience )
149+ try :
150+ decoded = jwt .decode (token , verify = False )
151+ expires_at = decoded .get ("exp" ) or int (time .time () + 3600 )
152+ except Exception : # pylint: disable=broad-except
153+ expires_at = int (time .time () + 3600 )
106154 else :
107155 # Guaranteed non-None by ServiceAccount model_validator.
108156 assert sa_config .service_account_credential is not None
@@ -114,14 +162,24 @@ def _exchange_for_id_token(self, sa_config: ServiceAccount) -> AuthCredential:
114162 )
115163 credentials .refresh (Request ())
116164 token = credentials .token
117-
118- return AuthCredential (
165+ try :
166+ expires_at = (
167+ calendar .timegm (credentials .expiry .utctimetuple ())
168+ if credentials .expiry
169+ else int (time .time () + 3600 )
170+ )
171+ except (AttributeError , TypeError , ValueError ):
172+ expires_at = int (time .time () + 3600 )
173+
174+ res = AuthCredential (
119175 auth_type = AuthCredentialTypes .HTTP ,
120176 http = HttpAuth (
121177 scheme = "bearer" ,
122178 credentials = HttpCredentials (token = token ),
123179 ),
124180 )
181+ _id_token_cache [cache_key ] = (res , expires_at )
182+ return res
125183
126184 # ValueError is raised by google-auth when service account JSON is
127185 # missing required fields (e.g. client_email, private_key), or when
@@ -146,6 +204,13 @@ def _exchange_for_access_token(
146204 AuthCredentialMissingError: If scopes are missing for explicit
147205 credentials or token exchange fails.
148206 """
207+ cache_key = _get_cache_key (sa_config )
208+ cached_val = _access_token_cache .get (cache_key )
209+ if cached_val :
210+ token , expires_at = cached_val
211+ if time .time () < expires_at - 300 :
212+ return token
213+
149214 if not sa_config .use_default_credential and not sa_config .scopes :
150215 raise AuthCredentialMissingError (
151216 "scopes are required when using explicit service account credentials"
@@ -173,8 +238,16 @@ def _exchange_for_access_token(
173238 quota_project_id = None
174239
175240 credentials .refresh (Request ())
241+ try :
242+ expires_at = (
243+ calendar .timegm (credentials .expiry .utctimetuple ())
244+ if credentials .expiry
245+ else int (time .time () + 3600 )
246+ )
247+ except (AttributeError , TypeError , ValueError ):
248+ expires_at = int (time .time () + 3600 )
176249
177- return AuthCredential (
250+ res = AuthCredential (
178251 auth_type = AuthCredentialTypes .HTTP ,
179252 http = HttpAuth (
180253 scheme = "bearer" ,
@@ -186,6 +259,8 @@ def _exchange_for_access_token(
186259 else None ,
187260 ),
188261 )
262+ _access_token_cache [cache_key ] = (res , expires_at )
263+ return res
189264
190265 # ValueError is raised by google-auth when service account JSON is
191266 # missing required fields (e.g. client_email, private_key).
0 commit comments