Skip to content

Commit 7be90db

Browse files
GWealecopybara-github
authored andcommitted
feat: Support ID token exchange in ServiceAccountCredentialExchanger
Adds use_id_token and audience fields to ServiceAccount so that ServiceAccountCredentialExchanger can produce ID tokens instead of access tokens. This is required for authenticating to Cloud Run, Cloud Functions, and other Google Cloud services that verify caller identity. Close #4458 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 874630210
1 parent c615757 commit 7be90db

File tree

4 files changed

+402
-106
lines changed

4 files changed

+402
-106
lines changed

src/google/adk/auth/auth_credential.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from pydantic import BaseModel
2626
from pydantic import ConfigDict
2727
from pydantic import Field
28+
from pydantic import model_validator
2829

2930

3031
class BaseModelWithConfig(BaseModel):
@@ -145,11 +146,45 @@ class ServiceAccountCredential(BaseModelWithConfig):
145146

146147

147148
class ServiceAccount(BaseModelWithConfig):
148-
"""Represents Google Service Account configuration."""
149+
"""Represents Google Service Account configuration.
150+
151+
Attributes:
152+
service_account_credential: The service account credential (JSON key).
153+
scopes: The OAuth2 scopes to request. Optional; when omitted with
154+
``use_default_credential=True``, defaults to the cloud-platform scope.
155+
use_default_credential: Whether to use Application Default Credentials.
156+
use_id_token: Whether to exchange for an ID token instead of an access
157+
token. Required for service-to-service authentication with Cloud Run,
158+
Cloud Functions, and other Google Cloud services that require identity
159+
verification. When True, ``audience`` must also be set.
160+
audience: The target audience for the ID token, typically the URL of the
161+
receiving service (e.g. ``https://my-service-xyz.run.app``). Required
162+
when ``use_id_token`` is True.
163+
"""
149164

150165
service_account_credential: Optional[ServiceAccountCredential] = None
151-
scopes: List[str]
166+
scopes: Optional[List[str]] = None
152167
use_default_credential: Optional[bool] = False
168+
use_id_token: Optional[bool] = False
169+
audience: Optional[str] = None
170+
171+
@model_validator(mode="after")
172+
def _validate_config(self) -> ServiceAccount:
173+
if (
174+
not self.use_default_credential
175+
and self.service_account_credential is None
176+
):
177+
raise ValueError(
178+
"service_account_credential is required when"
179+
" use_default_credential is False."
180+
)
181+
if self.use_id_token and not self.audience:
182+
raise ValueError(
183+
"audience is required when use_id_token is True. Set it to the"
184+
" URL of the target service"
185+
" (e.g. 'https://my-service.run.app')."
186+
)
187+
return self
153188

154189

155190
class AuthCredentialTypes(str, Enum):

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

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from typing import Optional
2020

2121
import google.auth
22+
from google.auth import exceptions as google_auth_exceptions
2223
from google.auth.transport.requests import Request
2324
from google.oauth2 import service_account
2425
import google.oauth2.credentials
@@ -27,6 +28,7 @@
2728
from .....auth.auth_credential import AuthCredentialTypes
2829
from .....auth.auth_credential import HttpAuth
2930
from .....auth.auth_credential import HttpCredentials
31+
from .....auth.auth_credential import ServiceAccount
3032
from .....auth.auth_schemes import AuthScheme
3133
from .base_credential_exchanger import AuthCredentialMissingError
3234
from .base_credential_exchanger import BaseAuthCredentialExchanger
@@ -38,59 +40,142 @@ class ServiceAccountCredentialExchanger(BaseAuthCredentialExchanger):
3840
Uses the default service credential if `use_default_credential = True`.
3941
Otherwise, uses the service account credential provided in the auth
4042
credential.
43+
44+
Supports exchanging for either an access token (default) or an ID token
45+
when ``ServiceAccount.use_id_token`` is True. ID tokens are required for
46+
service-to-service authentication with Cloud Run, Cloud Functions, and
47+
other services that verify caller identity.
4148
"""
4249

4350
def exchange_credential(
4451
self,
4552
auth_scheme: AuthScheme,
4653
auth_credential: Optional[AuthCredential] = None,
4754
) -> AuthCredential:
48-
"""Exchanges the service account auth credential for an access token.
55+
"""Exchanges the service account auth credential for a token.
4956
5057
If auth_credential contains a service account credential, it will be used
51-
to fetch an access token. Otherwise, the default service credential will be
52-
used for fetching an access token.
58+
to fetch a token. Otherwise, the default service credential will be
59+
used for fetching a token.
60+
61+
When ``service_account.use_id_token`` is True, an ID token is fetched
62+
using the configured ``audience``. This is required for authenticating
63+
to Cloud Run, Cloud Functions, and similar services.
5364
5465
Args:
5566
auth_scheme: The auth scheme.
5667
auth_credential: The auth credential.
5768
5869
Returns:
59-
An AuthCredential in HTTPBearer format, containing the access token.
70+
An AuthCredential in HTTPBearer format, containing the token.
6071
"""
61-
if (
62-
auth_credential is None
63-
or auth_credential.service_account is None
64-
or (
65-
auth_credential.service_account.service_account_credential is None
66-
and not auth_credential.service_account.use_default_credential
67-
)
68-
):
72+
if auth_credential is None or auth_credential.service_account is None:
6973
raise AuthCredentialMissingError(
70-
"Service account credentials are missing. Please provide them, or set"
71-
" `use_default_credential = True` to use application default"
74+
"Service account credentials are missing. Please provide them, or"
75+
" set `use_default_credential = True` to use application default"
7276
" credential in a hosted service like Cloud Run."
7377
)
7478

79+
sa_config = auth_credential.service_account
80+
81+
if sa_config.use_id_token:
82+
return self._exchange_for_id_token(sa_config)
83+
84+
return self._exchange_for_access_token(sa_config)
85+
86+
def _exchange_for_id_token(self, sa_config: ServiceAccount) -> AuthCredential:
87+
"""Exchanges the service account credential for an ID token.
88+
89+
Args:
90+
sa_config: The service account configuration.
91+
92+
Returns:
93+
An AuthCredential in HTTPBearer format containing the ID token.
94+
95+
Raises:
96+
AuthCredentialMissingError: If token exchange fails.
97+
"""
98+
# audience and credential presence are validated by the ServiceAccount
99+
# model_validator at construction time.
75100
try:
76-
if auth_credential.service_account.use_default_credential:
77-
credentials, project_id = google.auth.default(
78-
scopes=["https://www.googleapis.com/auth/cloud-platform"],
101+
if sa_config.use_default_credential:
102+
from google.oauth2 import id_token as oauth2_id_token
103+
104+
request = Request()
105+
token = oauth2_id_token.fetch_id_token(request, sa_config.audience)
106+
else:
107+
# Guaranteed non-None by ServiceAccount model_validator.
108+
assert sa_config.service_account_credential is not None
109+
credentials = (
110+
service_account.IDTokenCredentials.from_service_account_info(
111+
sa_config.service_account_credential.model_dump(),
112+
target_audience=sa_config.audience,
113+
)
79114
)
80-
quota_project_id = (
81-
getattr(credentials, "quota_project_id", None) or project_id
115+
credentials.refresh(Request())
116+
token = credentials.token
117+
118+
return AuthCredential(
119+
auth_type=AuthCredentialTypes.HTTP,
120+
http=HttpAuth(
121+
scheme="bearer",
122+
credentials=HttpCredentials(token=token),
123+
),
124+
)
125+
126+
# ValueError is raised by google-auth when service account JSON is
127+
# missing required fields (e.g. client_email, private_key), or when
128+
# fetch_id_token cannot determine credentials from the environment.
129+
except (google_auth_exceptions.GoogleAuthError, ValueError) as e:
130+
raise AuthCredentialMissingError(
131+
f"Failed to exchange service account for ID token: {e}"
132+
) from e
133+
134+
def _exchange_for_access_token(
135+
self, sa_config: ServiceAccount
136+
) -> AuthCredential:
137+
"""Exchanges the service account credential for an access token.
138+
139+
Args:
140+
sa_config: The service account configuration.
141+
142+
Returns:
143+
An AuthCredential in HTTPBearer format containing the access token.
144+
145+
Raises:
146+
AuthCredentialMissingError: If scopes are missing for explicit
147+
credentials or token exchange fails.
148+
"""
149+
if not sa_config.use_default_credential and not sa_config.scopes:
150+
raise AuthCredentialMissingError(
151+
"scopes are required when using explicit service account credentials"
152+
" for access token exchange."
153+
)
154+
155+
try:
156+
if sa_config.use_default_credential:
157+
scopes = (
158+
sa_config.scopes
159+
if sa_config.scopes
160+
else ["https://www.googleapis.com/auth/cloud-platform"]
161+
)
162+
credentials, project_id = google.auth.default(
163+
scopes=scopes,
82164
)
165+
quota_project_id = credentials.quota_project_id or project_id
83166
else:
84-
config = auth_credential.service_account
167+
# Guaranteed non-None by ServiceAccount model_validator.
168+
assert sa_config.service_account_credential is not None
85169
credentials = service_account.Credentials.from_service_account_info(
86-
config.service_account_credential.model_dump(), scopes=config.scopes
170+
sa_config.service_account_credential.model_dump(),
171+
scopes=sa_config.scopes,
87172
)
88173
quota_project_id = None
89174

90175
credentials.refresh(Request())
91176

92-
updated_credential = AuthCredential(
93-
auth_type=AuthCredentialTypes.HTTP, # Store as a bearer token
177+
return AuthCredential(
178+
auth_type=AuthCredentialTypes.HTTP,
94179
http=HttpAuth(
95180
scheme="bearer",
96181
credentials=HttpCredentials(token=credentials.token),
@@ -101,9 +186,10 @@ def exchange_credential(
101186
else None,
102187
),
103188
)
104-
return updated_credential
105189

106-
except Exception as e:
190+
# ValueError is raised by google-auth when service account JSON is
191+
# missing required fields (e.g. client_email, private_key).
192+
except (google_auth_exceptions.GoogleAuthError, ValueError) as e:
107193
raise AuthCredentialMissingError(
108194
f"Failed to exchange service account token: {e}"
109195
) from e

tests/unittests/tools/mcp_tool/test_mcp_tool.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,9 @@ async def test_get_headers_service_account(self):
534534
)
535535

536536
# Create service account credential
537-
service_account = ServiceAccount(scopes=["test"])
537+
service_account = ServiceAccount(
538+
scopes=["test"], use_default_credential=True
539+
)
538540
credential = AuthCredential(
539541
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
540542
service_account=service_account,

0 commit comments

Comments
 (0)