Skip to content

Commit beb66ee

Browse files
wukathcopybara-github
authored andcommitted
fix: redact secret credentials from AuthCredential repr and error messages
Prevent sensitive credential fields (api_key, password, token, access_token, private_key, etc.) from being interpolated into exception messages and repr outputs in McpTool, RestApiTool, and AuthCredential models. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 962333594
1 parent bd239ce commit beb66ee

6 files changed

Lines changed: 230 additions & 21 deletions

File tree

src/google/adk/auth/auth_credential.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
from __future__ import annotations
1616

1717
from enum import Enum
18+
from typing import Annotated
1819
from typing import Any
1920
from typing import Dict
21+
from typing import Iterator
2022
from typing import List
2123
from typing import Literal
2224

@@ -26,22 +28,51 @@
2628
from pydantic import Field
2729
from pydantic import model_validator
2830

31+
_REDACTED = "<redacted>"
32+
33+
34+
# Pydantic echoes the rejected value into ValidationError messages
35+
# ("input_value=..."), which would put a malformed secret straight into logs and
36+
# into the error strings surfaced to the LLM. The field name and error type are
37+
# still reported. Passed as a class keyword rather than added to `model_config`
38+
# below: `model_config` states what these models accept, and rewriting that
39+
# declaration reads as an API change to the breaking-change detector even though
40+
# nothing about what they accept has changed.
41+
class BaseModelWithConfig(BaseModel, hide_input_in_errors=True):
42+
"""Base model for credential types, hardened against leaking secrets."""
2943

30-
class BaseModelWithConfig(BaseModel):
3144
model_config = ConfigDict(
3245
extra="allow",
3346
alias_generator=alias_generators.to_camel,
3447
populate_by_name=True,
3548
)
3649
"""The pydantic model config."""
3750

51+
def __repr_args__(self) -> Iterator[tuple[str | None, Any]]:
52+
"""Redacts the values of extra (unmodeled) fields from repr and str.
53+
54+
`extra="allow"` lets callers attach arbitrary keys to these credential
55+
models, and pydantic renders extras in repr unconditionally: marking a
56+
declared field `repr=False` does nothing for a secret that arrives under an
57+
unexpected key (e.g. a non-standard field in an OAuth2 token response).
58+
Redacting the values keeps them out of logs and out of error strings that
59+
reach the LLM, while still showing which keys were set.
60+
61+
Yields:
62+
`(name, value)` pairs to render, with the values of extra fields replaced
63+
by a redaction placeholder.
64+
"""
65+
extra = self.__pydantic_extra__ or {}
66+
for key, value in super().__repr_args__():
67+
yield key, _REDACTED if key in extra else value
68+
3869

3970
class HttpCredentials(BaseModelWithConfig):
4071
"""Represents the secret token value for HTTP authentication, like user name, password, oauth token, etc."""
4172

4273
username: str | None = None
43-
password: str | None = None
44-
token: str | None = None
74+
password: Annotated[str | None, Field(repr=False)] = None
75+
token: Annotated[str | None, Field(repr=False)] = None
4576

4677
@classmethod
4778
def model_validate(cls, data: Dict[str, Any]) -> "HttpCredentials":
@@ -61,14 +92,14 @@ class HttpAuth(BaseModelWithConfig):
6192
# Examples: 'basic', 'bearer'
6293
scheme: str
6394
credentials: HttpCredentials
64-
additional_headers: Dict[str, str] | None = None
95+
additional_headers: Annotated[dict[str, str] | None, Field(repr=False)] = None
6596

6697

6798
class OAuth2Auth(BaseModelWithConfig):
6899
"""Represents credential value and its metadata for a OAuth2 credential."""
69100

70101
client_id: str | None = None
71-
client_secret: str | None = None
102+
client_secret: Annotated[str | None, Field(repr=False)] = None
72103
# tool or adk can generate the auth_uri with the state info thus client
73104
# can verify the state
74105
auth_uri: str | None = None
@@ -79,16 +110,16 @@ class OAuth2Auth(BaseModelWithConfig):
79110
state: str | None = None
80111
# tool or adk can decide the redirect_uri if they don't want client to decide
81112
redirect_uri: str | None = None
82-
auth_response_uri: str | None = None
83-
auth_code: str | None = None
84-
access_token: str | None = None
85-
refresh_token: str | None = None
86-
id_token: str | None = None
113+
auth_response_uri: Annotated[str | None, Field(repr=False)] = None
114+
auth_code: Annotated[str | None, Field(repr=False)] = None
115+
access_token: Annotated[str | None, Field(repr=False)] = None
116+
refresh_token: Annotated[str | None, Field(repr=False)] = None
117+
id_token: Annotated[str | None, Field(repr=False)] = None
87118
expires_at: int | None = None
88119
expires_in: int | None = None
89120
audience: str | None = None
90121
prompt: str | None = None
91-
code_verifier: str | None = None
122+
code_verifier: Annotated[str | None, Field(repr=False)] = None
92123
code_challenge_method: str | None = None
93124
token_endpoint_auth_method: (
94125
Literal[
@@ -141,8 +172,8 @@ class ServiceAccountCredential(BaseModelWithConfig):
141172

142173
type_: str = Field("", alias="type")
143174
project_id: str
144-
private_key_id: str
145-
private_key: str
175+
private_key_id: Annotated[str, Field(repr=False)]
176+
private_key: Annotated[str, Field(repr=False)]
146177
client_email: str
147178
client_id: str
148179
auth_uri: str
@@ -280,7 +311,7 @@ class AuthCredential(BaseModelWithConfig):
280311
# This will be supported in the future.
281312
resource_ref: str | None = None
282313

283-
api_key: str | None = None
314+
api_key: Annotated[str | None, Field(repr=False)] = None
284315
http: HttpAuth | None = None
285316
service_account: ServiceAccount | None = None
286317
oauth2: OAuth2Auth | None = None

src/google/adk/tools/mcp_tool/mcp_tool.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -585,8 +585,7 @@ async def _get_headers(
585585
or not self._credentials_manager._auth_config
586586
):
587587
error_msg = (
588-
"Cannot find corresponding auth scheme for API key credential"
589-
f" {credential}"
588+
"Cannot find corresponding auth scheme for API key credential."
590589
)
591590
logger.error(error_msg)
592591
raise ValueError(error_msg)

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,8 +604,7 @@ def __repr__(self):
604604
return (
605605
f'RestApiTool(name="{self.name}", description="{self.description}",'
606606
f' endpoint="{self.endpoint}", operation="{self.operation}",'
607-
f' auth_scheme="{self.auth_scheme}",'
608-
f' auth_credential="{self.auth_credential}")'
607+
f' auth_scheme="{self.auth_scheme}")'
609608
)
610609

611610

tests/unittests/auth/test_auth_credential.py

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Tests for the shared base model behind the auth credential models."""
15+
"""Tests for the auth credential models and their shared base model."""
1616

1717
from __future__ import annotations
1818

19+
from google.adk.auth.auth_credential import AuthCredential
20+
from google.adk.auth.auth_credential import AuthCredentialTypes
1921
from google.adk.auth.auth_credential import BaseModelWithConfig
22+
from google.adk.auth.auth_credential import HttpAuth
23+
from google.adk.auth.auth_credential import HttpCredentials
24+
from google.adk.auth.auth_credential import OAuth2Auth
25+
from google.adk.auth.auth_credential import ServiceAccountCredential
26+
import pydantic
27+
import pytest
2028

2129

2230
class _Sample(BaseModelWithConfig):
@@ -46,3 +54,146 @@ def test_base_model_with_config_dumps_camel_case_only_when_asked():
4654
model = _Sample(access_token='abc')
4755
assert model.model_dump()['access_token'] == 'abc'
4856
assert model.model_dump(by_alias=True)['accessToken'] == 'abc'
57+
58+
59+
def test_api_key_redacted_in_repr_and_str():
60+
"""An API key is not rendered, but is still readable on the model."""
61+
cred = AuthCredential(
62+
auth_type=AuthCredentialTypes.API_KEY,
63+
api_key='sk-live-secret-api-key-12345',
64+
)
65+
repr_str = repr(cred)
66+
str_str = str(cred)
67+
assert 'sk-live-secret-api-key-12345' not in repr_str
68+
assert 'sk-live-secret-api-key-12345' not in str_str
69+
# Only the rendering is redacted; the value itself is untouched.
70+
assert cred.api_key == 'sk-live-secret-api-key-12345'
71+
72+
73+
def test_http_credentials_redacted_in_repr_and_str():
74+
"""HTTP passwords, tokens and auth headers are not rendered."""
75+
cred = AuthCredential(
76+
auth_type=AuthCredentialTypes.HTTP,
77+
http=HttpAuth(
78+
scheme='basic',
79+
credentials=HttpCredentials(
80+
username='my_user',
81+
password='secret_password_999',
82+
token='secret_token_abc',
83+
),
84+
additional_headers={'Authorization': 'Bearer secret_bearer_token'},
85+
),
86+
)
87+
repr_str = repr(cred)
88+
str_str = str(cred)
89+
assert 'secret_password_999' not in repr_str
90+
assert 'secret_token_abc' not in repr_str
91+
assert 'secret_bearer_token' not in repr_str
92+
assert 'secret_password_999' not in str_str
93+
assert 'secret_token_abc' not in str_str
94+
95+
96+
def test_oauth2_credentials_redacted_in_repr_and_str():
97+
"""OAuth2 secrets, tokens and the auth response URI are not rendered."""
98+
cred = AuthCredential(
99+
auth_type=AuthCredentialTypes.OAUTH2,
100+
oauth2=OAuth2Auth(
101+
client_id='my_client_id',
102+
client_secret='top_secret_client_secret',
103+
access_token='secret_access_token',
104+
refresh_token='secret_refresh_token',
105+
id_token='secret_id_token',
106+
auth_code='secret_auth_code',
107+
auth_response_uri=(
108+
'https://example.com/callback?code=secret_response_code'
109+
),
110+
code_verifier='secret_code_verifier',
111+
),
112+
)
113+
repr_str = repr(cred)
114+
str_str = str(cred)
115+
assert 'top_secret_client_secret' not in repr_str
116+
assert 'secret_access_token' not in repr_str
117+
assert 'secret_refresh_token' not in repr_str
118+
assert 'secret_id_token' not in repr_str
119+
assert 'secret_auth_code' not in repr_str
120+
assert 'secret_response_code' not in repr_str
121+
assert 'secret_code_verifier' not in repr_str
122+
assert 'top_secret_client_secret' not in str_str
123+
assert 'secret_response_code' not in str_str
124+
125+
126+
def test_service_account_redacted_in_repr_and_str():
127+
"""A service account private key and its ID are not rendered."""
128+
sa_cred = ServiceAccountCredential(
129+
type_='service_account',
130+
project_id='test_project',
131+
private_key_id='secret_private_key_id',
132+
private_key=(
133+
'-----BEGIN PRIVATE KEY-----\nsecret_key_data\n-----END PRIVATE'
134+
' KEY-----'
135+
),
136+
client_email='test@iam.gserviceaccount.com',
137+
client_id='12345',
138+
auth_uri='https://example.com/o/oauth2/auth',
139+
token_uri='https://example.com/token',
140+
auth_provider_x509_cert_url='https://example.com/oauth2/v1/certs',
141+
client_x509_cert_url='https://example.com/robot/v1/metadata/x509/test',
142+
universe_domain='example.com',
143+
)
144+
repr_str = repr(sa_cred)
145+
str_str = str(sa_cred)
146+
assert 'secret_key_data' not in repr_str
147+
assert 'secret_private_key_id' not in repr_str
148+
assert 'secret_key_data' not in str_str
149+
assert 'secret_private_key_id' not in str_str
150+
151+
152+
def test_extra_fields_redacted_in_repr_and_str():
153+
"""A secret under an undeclared key is redacted, not rendered."""
154+
# `extra="allow"` means a secret can arrive under a key the model does not
155+
# declare, which pydantic would otherwise render in repr unconditionally.
156+
cred = AuthCredential.model_validate({
157+
'auth_type': AuthCredentialTypes.API_KEY,
158+
'undeclared_secret': 'secret_extra_value',
159+
})
160+
repr_str = repr(cred)
161+
str_str = str(cred)
162+
assert 'secret_extra_value' not in repr_str
163+
assert 'secret_extra_value' not in str_str
164+
# The key is still surfaced so the redaction is visible when debugging, and
165+
# the value remains readable programmatically.
166+
assert 'undeclared_secret' in repr_str
167+
assert cred.undeclared_secret == 'secret_extra_value'
168+
169+
170+
def test_nested_extra_fields_redacted_in_repr_and_str():
171+
"""Undeclared keys on a nested credential model are redacted too."""
172+
# Mirrors an OAuth2 provider returning a non-standard token field.
173+
cred = AuthCredential(
174+
auth_type=AuthCredentialTypes.OAUTH2,
175+
oauth2=OAuth2Auth.model_validate({
176+
'client_id': 'my_client_id',
177+
'unexpected_token': 'secret_unexpected_token',
178+
}),
179+
)
180+
repr_str = repr(cred)
181+
str_str = str(cred)
182+
assert 'secret_unexpected_token' not in repr_str
183+
assert 'secret_unexpected_token' not in str_str
184+
assert 'my_client_id' in repr_str
185+
186+
187+
def test_validation_error_does_not_echo_secret_value():
188+
"""A rejected value is not echoed back in the ValidationError text."""
189+
# Pydantic reports the rejected value as `input_value=...` by default, which
190+
# would put the secret into the error string surfaced to the LLM.
191+
with pytest.raises(pydantic.ValidationError) as exc_info:
192+
AuthCredential.model_validate({
193+
'auth_type': AuthCredentialTypes.API_KEY,
194+
'api_key': ['sk-live-secret-api-key-12345'],
195+
})
196+
message = str(exc_info.value)
197+
assert 'sk-live-secret-api-key-12345' not in message
198+
# The field and the reason are still reported.
199+
assert 'api_key' in message

tests/unittests/tools/mcp_tool/test_mcp_tool.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,11 @@ async def test_get_headers_api_key_without_auth_config_raises_error(self):
605605
with pytest.raises(
606606
ValueError,
607607
match="Cannot find corresponding auth scheme for API key credential",
608-
):
608+
) as exc_info:
609609
await tool._get_headers(tool_context, credential)
610610

611+
assert "my_api_key" not in str(exc_info.value)
612+
611613
@pytest.mark.asyncio
612614
async def test_get_headers_api_key_without_credentials_manager_raises_error(
613615
self,
@@ -629,9 +631,11 @@ async def test_get_headers_api_key_without_credentials_manager_raises_error(
629631
with pytest.raises(
630632
ValueError,
631633
match="Cannot find corresponding auth scheme for API key credential",
632-
):
634+
) as exc_info:
633635
await tool._get_headers(tool_context, credential)
634636

637+
assert "my_api_key" not in str(exc_info.value)
638+
635639
@pytest.mark.asyncio
636640
async def test_get_headers_no_credential(self):
637641
"""Test header generation with no credentials."""

tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,31 @@ def test_prepare_request_params_plain_url_unchanged(
17091709

17101710
assert request_params["url"] == "https://example.com/test"
17111711

1712+
def test_rest_api_tool_repr_and_str(
1713+
self, sample_endpoint, sample_operation, sample_auth_scheme
1714+
):
1715+
"""The attached credential is not rendered into repr or str."""
1716+
secret_cred = AuthCredential(
1717+
auth_type=AuthCredentialTypes.API_KEY,
1718+
api_key="sk-live-secret-api-key-12345",
1719+
)
1720+
tool = RestApiTool(
1721+
name="test_tool",
1722+
description="test description",
1723+
endpoint=sample_endpoint,
1724+
operation=sample_operation,
1725+
auth_scheme=sample_auth_scheme,
1726+
auth_credential=secret_cred,
1727+
)
1728+
repr_str = repr(tool)
1729+
str_str = str(tool)
1730+
assert 'name="test_tool"' in repr_str
1731+
assert 'description="test description"' in repr_str
1732+
assert "auth_scheme=" in repr_str
1733+
assert "auth_credential=" not in repr_str
1734+
assert "sk-live-secret-api-key-12345" not in repr_str
1735+
assert "sk-live-secret-api-key-12345" not in str_str
1736+
17121737

17131738
def test_snake_to_lower_camel():
17141739
assert snake_to_lower_camel("single") == "single"

0 commit comments

Comments
 (0)