Skip to content

Commit 33012e6

Browse files
GWealecopybara-github
authored andcommitted
fix: Make credential key generation stable and prevent cross-user credential leaks
This change updates the credential key generation to use a stable hash (SHA256) instead of Python's built-in hash, which can vary based on PYTHONHASHSEED. It also makes sure that temporary or exchanged OAuth2 fields are excluded from the key calculation. I also added when saving credentials, a copy of the AuthConfig is used to avoid modifying the original shared AuthConfig instance with user-specific exchanged credentials. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 864599326
1 parent 666cebe commit 33012e6

File tree

11 files changed

+448
-43
lines changed

11 files changed

+448
-43
lines changed

src/google/adk/auth/auth_handler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def generate_auth_request(self) -> AuthConfig:
115115
exchanged_auth_credential=self.auth_config.raw_auth_credential.model_copy(
116116
deep=True
117117
),
118+
credential_key=self.auth_config.credential_key,
118119
)
119120

120121
# Check for client_id and client_secret
@@ -133,6 +134,7 @@ def generate_auth_request(self) -> AuthConfig:
133134
auth_scheme=self.auth_config.auth_scheme,
134135
raw_auth_credential=self.auth_config.raw_auth_credential,
135136
exchanged_auth_credential=exchanged_credential,
137+
credential_key=self.auth_config.credential_key,
136138
)
137139

138140
def generate_auth_uri(

src/google/adk/auth/auth_preprocessor.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,46 @@ async def run_async(
7272
if not responses:
7373
return
7474

75+
requested_auth_config_by_request_id = {}
7576
# look for auth response
7677
for function_call_response in responses:
7778
if function_call_response.name != REQUEST_EUC_FUNCTION_CALL_NAME:
7879
continue
7980
# found the function call response for the system long running request euc
8081
# function call
8182
request_euc_function_call_ids.add(function_call_response.id)
83+
84+
if request_euc_function_call_ids:
85+
for event in events:
86+
function_calls = event.get_function_calls()
87+
if not function_calls:
88+
continue
89+
try:
90+
for function_call in function_calls:
91+
if (
92+
function_call.id in request_euc_function_call_ids
93+
and function_call.name == REQUEST_EUC_FUNCTION_CALL_NAME
94+
):
95+
args = AuthToolArguments.model_validate(function_call.args)
96+
requested_auth_config_by_request_id[function_call.id] = (
97+
args.auth_config
98+
)
99+
except TypeError:
100+
continue
101+
102+
for function_call_response in responses:
103+
if function_call_response.name != REQUEST_EUC_FUNCTION_CALL_NAME:
104+
continue
105+
82106
auth_config = AuthConfig.model_validate(function_call_response.response)
107+
requested_auth_config = requested_auth_config_by_request_id.get(
108+
function_call_response.id
109+
)
110+
if (
111+
requested_auth_config
112+
and requested_auth_config.credential_key is not None
113+
):
114+
auth_config.credential_key = requested_auth_config.credential_key
83115
await AuthHandler(auth_config=auth_config).parse_and_store_auth_response(
84116
state=invocation_context.session.state
85117
)

src/google/adk/auth/auth_tool.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,40 @@
1414

1515
from __future__ import annotations
1616

17+
import hashlib
18+
import json
1719
from typing import Optional
1820

21+
from pydantic import BaseModel
1922
from typing_extensions import deprecated
2023

2124
from .auth_credential import AuthCredential
2225
from .auth_credential import BaseModelWithConfig
2326
from .auth_schemes import AuthScheme
2427

2528

29+
def _stable_model_digest(model: BaseModel) -> str:
30+
"""Returns a stable digest for a pydantic model.
31+
32+
The digest is stable across:
33+
- Python hash seeds (does not use `hash()`).
34+
- Dict insertion ordering differences (canonicalizes via `sort_keys=True`).
35+
- Pydantic `model_extra` values (ignored).
36+
"""
37+
if getattr(model, "model_extra", None):
38+
model = model.model_copy(deep=True)
39+
model.model_extra.clear()
40+
41+
dumped = model.model_dump(by_alias=True, exclude_none=True, mode="json")
42+
canonical_json = json.dumps(
43+
dumped,
44+
sort_keys=True,
45+
ensure_ascii=False,
46+
separators=(",", ":"),
47+
)
48+
return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16]
49+
50+
2651
class AuthConfig(BaseModelWithConfig):
2752
"""The auth config sent by tool asking client to collect auth credentials and
2853
@@ -58,12 +83,22 @@ def __init__(self, **data):
5883
super().__init__(**data)
5984
if self.credential_key:
6085
return
86+
for obj in (self.raw_auth_credential, self.auth_scheme):
87+
if not obj or not getattr(obj, "model_extra", None):
88+
continue
89+
for key in ("credential_key", "credentialKey"):
90+
value = obj.model_extra.get(key)
91+
if isinstance(value, str) and value:
92+
self.credential_key = value
93+
return
6194
self.credential_key = self.get_credential_key()
6295

6396
@deprecated("This method is deprecated. Use credential_key instead.")
6497
def get_credential_key(self):
65-
"""Builds a hash key based on auth_scheme and raw_auth_credential used to
66-
save / load this credential to / from a credentials service.
98+
"""Builds a stable key based on auth_scheme and raw_auth_credential.
99+
100+
This is used to save/load credentials to/from a credential service when
101+
`credential_key` is not explicitly provided.
67102
"""
68103

69104
auth_scheme = self.auth_scheme
@@ -72,7 +107,7 @@ def get_credential_key(self):
72107
auth_scheme = auth_scheme.model_copy(deep=True)
73108
auth_scheme.model_extra.clear()
74109
scheme_name = (
75-
f"{auth_scheme.type_.name}_{hash(auth_scheme.model_dump_json())}"
110+
f"{auth_scheme.type_.name}_{_stable_model_digest(auth_scheme)}"
76111
if auth_scheme
77112
else ""
78113
)
@@ -81,8 +116,18 @@ def get_credential_key(self):
81116
if auth_credential and auth_credential.model_extra:
82117
auth_credential = auth_credential.model_copy(deep=True)
83118
auth_credential.model_extra.clear()
119+
if auth_credential and auth_credential.oauth2:
120+
auth_credential = auth_credential.model_copy(deep=True)
121+
auth_credential.oauth2.auth_uri = None
122+
auth_credential.oauth2.state = None
123+
auth_credential.oauth2.auth_response_uri = None
124+
auth_credential.oauth2.auth_code = None
125+
auth_credential.oauth2.access_token = None
126+
auth_credential.oauth2.refresh_token = None
127+
auth_credential.oauth2.expires_at = None
128+
auth_credential.oauth2.expires_in = None
84129
credential_name = (
85-
f"{auth_credential.auth_type.value}_{hash(auth_credential.model_dump_json())}"
130+
f"{auth_credential.auth_type.value}_{_stable_model_digest(auth_credential)}"
86131
if auth_credential
87132
else ""
88133
)

src/google/adk/auth/credential_manager.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ async def get_auth_credential(
142142

143143
# Step 2: Check if credential is already ready (no processing needed)
144144
if self._is_credential_ready():
145-
return self._auth_config.raw_auth_credential
145+
# Return a copy to avoid leaking mutations across invocations/users when
146+
# tools share a long-lived AuthConfig instance.
147+
return self._auth_config.raw_auth_credential.model_copy(deep=True)
146148

147149
# Step 3: Try to load existing processed credential
148150
credential = await self._load_existing_credential(context)
@@ -159,7 +161,9 @@ async def get_auth_credential(
159161
if not credential:
160162
# For client credentials flow, use raw credentials directly
161163
if self._is_client_credentials_flow():
162-
credential = self._auth_config.raw_auth_credential
164+
# Exchange/refresh steps may mutate the credential object in-place, so
165+
# do not operate on the shared tool config.
166+
credential = self._auth_config.raw_auth_credential.model_copy(deep=True)
163167
else:
164168
# For authorization code flow, return None to trigger user authorization
165169
return None
@@ -298,12 +302,11 @@ async def _save_credential(
298302
self, context: CallbackContext, credential: AuthCredential
299303
) -> None:
300304
"""Save credential to credential service if available."""
301-
# Update the exchanged credential in config
302-
self._auth_config.exchanged_auth_credential = credential
303-
304305
credential_service = context._invocation_context.credential_service
305306
if credential_service:
306-
await context.save_credential(self._auth_config)
307+
auth_config_to_save = self._auth_config.model_copy(deep=True)
308+
auth_config_to_save.exchanged_auth_credential = credential
309+
await context.save_credential(auth_config_to_save)
307310

308311
async def _populate_auth_scheme(self) -> bool:
309312
"""Auto-discover server metadata and populate missing auth scheme info.

src/google/adk/tools/openapi_tool/openapi_spec_parser/openapi_toolset.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def __init__(
7070
spec_str_type: Literal["json", "yaml"] = "json",
7171
auth_scheme: Optional[AuthScheme] = None,
7272
auth_credential: Optional[AuthCredential] = None,
73+
credential_key: Optional[str] = None,
7374
tool_filter: Optional[Union[ToolPredicate, List[str]]] = None,
7475
tool_name_prefix: Optional[str] = None,
7576
ssl_verify: Optional[Union[bool, str, ssl.SSLContext]] = None,
@@ -108,6 +109,8 @@ def __init__(
108109
auth_credential: The auth credential to use for all tools. Use
109110
AuthCredential or use helpers in
110111
``google.adk.tools.openapi_tool.auth.auth_helpers``
112+
credential_key: Optional stable key used for interactive auth and
113+
credential caching across all tools in this toolset.
111114
tool_filter: The filter used to filter the tools in the toolset. It can be
112115
either a tool predicate or a list of tool names of the tools to expose.
113116
tool_name_prefix: The prefix to prepend to the names of the tools returned
@@ -137,6 +140,7 @@ def __init__(
137140
AuthConfig(
138141
auth_scheme=auth_scheme,
139142
raw_auth_credential=auth_credential,
143+
credential_key=credential_key,
140144
)
141145
if auth_scheme
142146
else None
@@ -147,6 +151,8 @@ def __init__(
147151
self._tools: Final[List[RestApiTool]] = list(self._parse(spec_dict))
148152
if auth_scheme or auth_credential:
149153
self._configure_auth_all(auth_scheme, auth_credential)
154+
if credential_key:
155+
self._configure_credential_key_all(credential_key)
150156

151157
def _configure_auth_all(
152158
self, auth_scheme: AuthScheme, auth_credential: AuthCredential
@@ -159,6 +165,11 @@ def _configure_auth_all(
159165
if auth_credential:
160166
tool.configure_auth_credential(auth_credential)
161167

168+
def _configure_credential_key_all(self, credential_key: str):
169+
"""Configure credential key for all tools."""
170+
for tool in self._tools:
171+
tool.configure_credential_key(credential_key)
172+
162173
def configure_ssl_verify_all(
163174
self, ssl_verify: Optional[Union[bool, str, ssl.SSLContext]] = None
164175
):
@@ -229,8 +240,9 @@ async def close(self):
229240
def get_auth_config(self) -> Optional[AuthConfig]:
230241
"""Returns the auth config for this toolset.
231242
232-
ADK will populate exchanged_auth_credential on this config before calling
233-
get_tools(). The toolset can then access the ready-to-use credential via
234-
self._auth_config.exchanged_auth_credential.
243+
Note: This returns a copy so any exchanged credentials populated by the ADK
244+
framework do not persist on the toolset instance across invocations.
235245
"""
236-
return self._auth_config
246+
return (
247+
self._auth_config.model_copy(deep=True) if self._auth_config else None
248+
)

src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def __init__(
100100
header_provider: Optional[
101101
Callable[[ReadonlyContext], Dict[str, str]]
102102
] = None,
103+
*,
104+
credential_key: Optional[str] = None,
103105
):
104106
"""Initializes the RestApiTool with the given parameters.
105107
@@ -137,6 +139,8 @@ def __init__(
137139
an argument, allowing dynamic header generation based on the current
138140
context. Useful for adding custom headers like correlation IDs,
139141
authentication tokens, or other request metadata.
142+
credential_key: Optional stable key used for interactive auth and
143+
credential caching.
140144
"""
141145
# Gemini restrict the length of function name to be less than 64 characters
142146
self.name = name[:60]
@@ -152,6 +156,7 @@ def __init__(
152156
else operation
153157
)
154158
self.auth_credential, self.auth_scheme = None, None
159+
self.credential_key = credential_key
155160

156161
self.configure_auth_credential(auth_credential)
157162
self.configure_auth_scheme(auth_scheme)
@@ -266,6 +271,10 @@ def configure_auth_credential(
266271
auth_credential = AuthCredential.model_validate_json(auth_credential)
267272
self.auth_credential = auth_credential
268273

274+
def configure_credential_key(self, credential_key: Optional[str] = None):
275+
"""Configures the credential key for interactive auth / caching."""
276+
self.credential_key = credential_key
277+
269278
def configure_ssl_verify(
270279
self, ssl_verify: Optional[Union[bool, str, ssl.SSLContext]] = None
271280
):
@@ -449,7 +458,10 @@ async def call(
449458
"""
450459
# Prepare auth credentials for the API call
451460
tool_auth_handler = ToolAuthHandler.from_tool_context(
452-
tool_context, self.auth_scheme, self.auth_credential
461+
tool_context,
462+
self.auth_scheme,
463+
self.auth_credential,
464+
credential_key=self.credential_key,
453465
)
454466
auth_result = await tool_auth_handler.prepare_auth_credentials()
455467
auth_state, auth_scheme, auth_credential = (

0 commit comments

Comments
 (0)