Skip to content

Commit 9897217

Browse files
a2105zcopybara-github
authored andcommitted
fix: use OAuth2 client-credentials scheme for OpenAPI SA helpers
Merge #6660 Change service-account OpenAPI helpers to return an OAuth2 client-credentials scheme so CredentialManager can perform token exchange. Also, bypass the credential service caching for all SERVICE_ACCOUNT credentials. This ensures we don't cache exchanged tokens that cannot be refreshed, but means token exchange will run on each tool execution if the manager/exchanger is not reused. Fixes #6656 PiperOrigin-RevId: 966305100
1 parent 0b39e72 commit 9897217

7 files changed

Lines changed: 488 additions & 16 deletions

File tree

scripts/compliance_checks.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@
4040
'src/google/adk/tools/_google_credentials.py',
4141
'src/google/adk/tools/apihub_tool/clients/apihub_client.py',
4242
'src/google/adk/tools/google_api_tool/google_api_toolset.py',
43-
'src/google/adk/tools/openapi_tool/auth/auth_helpers.py',
4443
'tests/unittests/auth/test_credential_manager.py',
4544
'tests/unittests/flows/llm_flows/test_functions_request_euc.py',
4645
'tests/unittests/tools/application_integration_tool/test_application_integration_toolset.py',

src/google/adk/auth/credential_manager.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,12 @@ async def get_auth_credential(
238238
return raw_auth_credential.model_copy(deep=True)
239239

240240
# Step 3: Try to load existing processed credential
241-
credential = await self._load_existing_credential(context)
241+
credential = None
242+
if not (
243+
raw_auth_credential
244+
and raw_auth_credential.auth_type == AuthCredentialTypes.SERVICE_ACCOUNT
245+
):
246+
credential = await self._load_existing_credential(context)
242247

243248
# Step 4: If no existing credential, load from auth response
244249
# TODO instead of load from auth response, we can store auth response in
@@ -269,7 +274,12 @@ async def get_auth_credential(
269274

270275
# Step 8: Save credential if it was modified
271276
if was_from_auth_response or was_exchanged or was_refreshed:
272-
await self._save_credential(context, credential)
277+
if not (
278+
raw_auth_credential
279+
and raw_auth_credential.auth_type
280+
== AuthCredentialTypes.SERVICE_ACCOUNT
281+
):
282+
await self._save_credential(context, credential)
273283

274284
return credential
275285

src/google/adk/tools/openapi_tool/auth/auth_helpers.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from fastapi.openapi.models import HTTPBase
2727
from fastapi.openapi.models import HTTPBearer
2828
from fastapi.openapi.models import OAuth2
29+
from fastapi.openapi.models import OAuthFlowClientCredentials
30+
from fastapi.openapi.models import OAuthFlows
2931
from fastapi.openapi.models import OpenIdConnect
3032
from fastapi.openapi.models import Schema
3133
import httpx
@@ -152,13 +154,40 @@ def token_to_scheme_credential(
152154
raise ValueError(f"Invalid security scheme type: {type}")
153155

154156

157+
def _service_account_auth_scheme() -> OAuth2:
158+
"""Auth scheme for Google Service Account credentials.
159+
160+
CredentialManager only auto-loads raw non-interactive credentials when the
161+
scheme is an OAuth2/OIDC client-credentials flow. An HTTPBearer scheme makes
162+
``_is_client_credentials_flow`` return False, so ``get_auth_credential``
163+
returns None and the tool falls back to ``adk_request_credential`` instead of
164+
exchanging the service account for a token.
165+
166+
The token URL is unused by ServiceAccountCredentialExchanger (ADC / JWT
167+
assertion), but is required by the OAuth2 client-credentials model.
168+
"""
169+
return OAuth2(
170+
flows=OAuthFlows(
171+
clientCredentials=OAuthFlowClientCredentials(
172+
# Placeholder only; SA exchange does not call this endpoint.
173+
# Use the mTLS host form for compliance with Google API endpoint
174+
# requirements.
175+
tokenUrl="https://oauth2.mtls.googleapis.com/token",
176+
scopes={},
177+
)
178+
)
179+
)
180+
181+
155182
def service_account_dict_to_scheme_credential(
156183
config: Dict[str, Any],
157184
scopes: List[str],
158185
) -> Tuple[AuthScheme, AuthCredential]:
159186
"""Creates AuthScheme and AuthCredential for Google Service Account.
160187
161-
Returns a bearer token scheme, and a service account credential.
188+
Returns an OAuth2 client-credentials scheme (so CredentialManager can
189+
exchange the service account) and a service account credential. After
190+
exchange the credential is an HTTP bearer token.
162191
163192
Args:
164193
config: A ServiceAccount object containing the Google Service Account
@@ -168,7 +197,6 @@ def service_account_dict_to_scheme_credential(
168197
Returns:
169198
Tuple: (AuthScheme, AuthCredential)
170199
"""
171-
auth_scheme = HTTPBearer(bearerFormat="JWT")
172200
service_account = ServiceAccount(
173201
service_account_credential=ServiceAccountCredential.model_construct(
174202
**config
@@ -179,15 +207,17 @@ def service_account_dict_to_scheme_credential(
179207
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
180208
service_account=service_account,
181209
)
182-
return auth_scheme, auth_credential
210+
return _service_account_auth_scheme(), auth_credential
183211

184212

185213
def service_account_scheme_credential(
186214
config: ServiceAccount,
187215
) -> Tuple[AuthScheme, AuthCredential]:
188216
"""Creates AuthScheme and AuthCredential for Google Service Account.
189217
190-
Returns a bearer token scheme, and a service account credential.
218+
Returns an OAuth2 client-credentials scheme (so CredentialManager can
219+
exchange the service account) and a service account credential. After
220+
exchange the credential is an HTTP bearer token.
191221
192222
Args:
193223
config: A ServiceAccount object containing the Google Service Account
@@ -196,11 +226,10 @@ def service_account_scheme_credential(
196226
Returns:
197227
Tuple: (AuthScheme, AuthCredential)
198228
"""
199-
auth_scheme = HTTPBearer(bearerFormat="JWT")
200229
auth_credential = AuthCredential(
201230
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT, service_account=config
202231
)
203-
return auth_scheme, auth_credential
232+
return _service_account_auth_scheme(), auth_credential
204233

205234

206235
def openid_dict_to_scheme_credential(

src/google/adk/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,14 @@
1616

1717
from __future__ import annotations
1818

19+
import calendar
20+
import time
21+
from typing import Any
1922
from typing import Optional
2023

2124
import google.auth
2225
from google.auth import exceptions as google_auth_exceptions
26+
from google.auth import jwt
2327
from google.auth.transport.requests import Request
2428
from google.oauth2 import service_account
2529
import google.oauth2.credentials
@@ -33,6 +37,38 @@
3337
from .base_credential_exchanger import AuthCredentialMissingError
3438
from .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

3773
class 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).

tests/unittests/auth/test_credential_manager.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,70 @@ async def test_load_auth_credentials_no_credential(self):
329329

330330
assert result is None
331331

332+
@pytest.mark.asyncio
333+
async def test_get_auth_credential_service_account_skips_cache(
334+
self, mocker, service_account_credential
335+
):
336+
"""Test that Service Account credentials bypass the load/save cache."""
337+
from fastapi.openapi.models import OAuth2
338+
from fastapi.openapi.models import OAuthFlowClientCredentials
339+
from fastapi.openapi.models import OAuthFlows
340+
from google.adk.auth.auth_credential import HttpAuth
341+
from google.adk.auth.auth_credential import HttpCredentials
342+
343+
auth_scheme = OAuth2(
344+
flows=OAuthFlows(
345+
clientCredentials=OAuthFlowClientCredentials(
346+
tokenUrl="https://example.com/token",
347+
scopes={},
348+
)
349+
)
350+
)
351+
352+
auth_config = AuthConfig(
353+
auth_scheme=auth_scheme,
354+
raw_auth_credential=service_account_credential,
355+
)
356+
357+
exchanged_credential = AuthCredential(
358+
auth_type=AuthCredentialTypes.HTTP,
359+
http=HttpAuth(
360+
scheme="bearer",
361+
credentials=HttpCredentials(token="sa-access-token"),
362+
),
363+
)
364+
365+
tool_context = mocker.Mock(spec=CallbackContext)
366+
367+
manager = CredentialManager(auth_config)
368+
369+
# Mock the private methods
370+
manager._validate_credential = mocker.AsyncMock()
371+
manager._is_credential_ready = mocker.Mock(return_value=False)
372+
manager._load_existing_credential = mocker.AsyncMock()
373+
manager._load_from_auth_response = mocker.AsyncMock(return_value=None)
374+
manager._exchange_credential = mocker.AsyncMock(
375+
return_value=(exchanged_credential, True)
376+
)
377+
manager._refresh_credential = mocker.AsyncMock(
378+
return_value=(exchanged_credential, False)
379+
)
380+
manager._save_credential = mocker.AsyncMock()
381+
manager._is_client_credentials_flow = mocker.Mock(return_value=True)
382+
383+
result = await manager.get_auth_credential(tool_context)
384+
385+
# Verify load and save were NOT called
386+
manager._load_existing_credential.assert_not_called()
387+
manager._save_credential.assert_not_called()
388+
389+
# Verify exchange WAS called
390+
manager._exchange_credential.assert_called_once()
391+
called_arg = manager._exchange_credential.call_args[0][0]
392+
assert called_arg.auth_type == AuthCredentialTypes.SERVICE_ACCOUNT
393+
394+
assert result == exchanged_credential
395+
332396
@pytest.mark.asyncio
333397
async def test_load_existing_credential_already_exchanged(self):
334398
"""Test _load_existing_credential ignores shared config cache."""

0 commit comments

Comments
 (0)