Skip to content

Commit 9b9aac0

Browse files
wukathcopybara-github
authored andcommitted
fix(auth): keep the OAuth2 client secret out of session state
`SessionStateCredentialService` wrote the exchanged credential to session state verbatim, so the agent's `client_secret` reached anything that can read a session. It is now stripped before storage and put back from the auth config in the store's own `load_credential`, which token refresh needs. The end user's own tokens are still stored in clear text; that is what this store is for, and its docstring now says so. Only new writes are affected. A session already persisted by a durable backend such as `DatabaseSessionService` or `SqliteSessionService` still holds the secret in clear text, so rotate any OAuth2 client secret an agent has used with one. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 987142295
1 parent ab4dba6 commit 9b9aac0

4 files changed

Lines changed: 282 additions & 51 deletions

File tree

‎src/google/adk/auth/auth_handler.py‎

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from .auth_schemes import OpenIdConnectWithConfig
2525
from .auth_tool import AuthConfig
2626
from .exchanger.oauth2_credential_exchanger import OAuth2CredentialExchanger
27+
from .oauth2_credential_util import _credential_without_client_secret
28+
from .oauth2_credential_util import _with_configured_client
2729

2830
if TYPE_CHECKING:
2931
from ..sessions.state import State
@@ -48,18 +50,6 @@ def _normalize_oauth_scopes(
4850
return list(scopes)
4951

5052

51-
def _credential_without_client_secret(
52-
credential: AuthCredential | None,
53-
) -> AuthCredential | None:
54-
"""Returns a copy of credential with the OAuth2 client secret removed."""
55-
if credential is None:
56-
return None
57-
redacted = credential.model_copy(deep=True)
58-
if redacted.oauth2 is not None:
59-
redacted.oauth2.client_secret = None
60-
return redacted
61-
62-
6353
def _without_client_secret(auth_config: AuthConfig) -> AuthConfig:
6454
"""Returns a copy of auth_config with OAuth2 client secrets removed.
6555
@@ -103,8 +93,9 @@ async def parse_and_store_auth_response(self, state: State) -> None:
10393

10494
temp_credential_key = "temp:" + credential_key
10595

106-
self.auth_config.exchanged_auth_credential = self._with_configured_client(
107-
self.auth_config.exchanged_auth_credential
96+
self.auth_config.exchanged_auth_credential = _with_configured_client(
97+
credential=self.auth_config.exchanged_auth_credential,
98+
raw_credential=self.auth_config.raw_auth_credential,
10899
)
109100
credential = self.auth_config.exchanged_auth_credential
110101
if self._is_exchangeable(credential):
@@ -117,28 +108,6 @@ def _validate(self) -> None:
117108
if not self.auth_config.auth_scheme:
118109
raise ValueError("auth_scheme is empty.")
119110

120-
def _with_configured_client(
121-
self, credential: AuthCredential | None
122-
) -> AuthCredential | None:
123-
"""Returns credential with the configured OAuth2 client identity restored.
124-
125-
The credential comes back from the client, which must not be able to
126-
choose which OAuth2 client the token is exchanged for. The original is
127-
left untouched, so the copy held in session state keeps no secret.
128-
"""
129-
raw_credential = self.auth_config.raw_auth_credential
130-
if (
131-
credential is None
132-
or credential.oauth2 is None
133-
or raw_credential is None
134-
or raw_credential.oauth2 is None
135-
):
136-
return credential
137-
restored = credential.model_copy(deep=True)
138-
restored.oauth2.client_id = raw_credential.oauth2.client_id
139-
restored.oauth2.client_secret = raw_credential.oauth2.client_secret
140-
return restored
141-
142111
def _is_exchangeable(self, credential: AuthCredential | None) -> bool:
143112
"""Returns whether credential still needs, and can do, a token exchange."""
144113
if not isinstance(
@@ -197,7 +166,10 @@ def get_auth_response(self, state: State) -> AuthCredential | None:
197166
return None
198167

199168
key, credential = stored
200-
credential = self._with_configured_client(credential)
169+
credential = _with_configured_client(
170+
credential=credential,
171+
raw_credential=self.auth_config.raw_auth_credential,
172+
)
201173
if not self._is_exchangeable(credential):
202174
return credential
203175

‎src/google/adk/auth/credential_service/session_state_credential_service.py‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import logging
1718
from typing import Optional
1819

1920
from typing_extensions import override
@@ -22,14 +23,24 @@
2223
from ...utils.feature_decorator import experimental
2324
from ..auth_credential import AuthCredential
2425
from ..auth_tool import AuthConfig
26+
from ..oauth2_credential_util import _credential_without_client_secret
27+
from ..oauth2_credential_util import _with_configured_client_secret
2528
from .base_credential_service import BaseCredentialService
2629

30+
logger = logging.getLogger("google_adk." + __name__)
31+
2732

2833
@experimental
2934
class SessionStateCredentialService(BaseCredentialService):
3035
"""Class for implementation of credential service using session state as the
3136
store.
37+
3238
Note: store credential in session may not be secure, use at your own risk.
39+
Session state is returned verbatim by the session-read endpoints, so whatever
40+
is kept here is readable by anything that can reach them. The agent's OAuth2
41+
client secret is stripped before storage and put back from the auth config on
42+
load; the end user's own access and refresh tokens are what this store exists
43+
to hold and are kept in clear text.
3344
"""
3445

3546
@override
@@ -54,7 +65,30 @@ async def load_credential(
5465
Optional[AuthCredential]: the credential saved in the store.
5566
5667
"""
57-
return callback_context.state.get(auth_config.credential_key)
68+
stored = callback_context.state.get(auth_config.credential_key)
69+
# A session service that persists state as JSON (DatabaseSessionService,
70+
# SqliteSessionService) hands back what `save_credential` wrote as a plain
71+
# dict, not as the model it was written as.
72+
if isinstance(stored, dict):
73+
stored = AuthCredential.model_validate(stored)
74+
elif stored is not None and not isinstance(stored, AuthCredential):
75+
# Something other than `save_credential` put this here. `AuthHandler`
76+
# accepts a bare token string under the same key, but this store's
77+
# contract is a credential or nothing.
78+
logger.warning(
79+
"Ignoring %s stored under the credential key: this store only reads"
80+
" credentials written by save_credential.",
81+
type(stored).__name__,
82+
)
83+
return None
84+
85+
# `save_credential` kept no client secret, so put the configured one back
86+
# here rather than in any one caller: a credential without it cannot
87+
# exchange or refresh.
88+
return _with_configured_client_secret(
89+
credential=stored,
90+
raw_credential=auth_config.raw_auth_credential,
91+
)
5892

5993
@override
6094
async def save_credential(
@@ -78,6 +112,10 @@ async def save_credential(
78112
None
79113
"""
80114

115+
# The client secret belongs to the agent's OAuth2 client, not to the end
116+
# user whose session this is, and session state is readable by anything
117+
# that can read the session. `load_credential` re-attaches it from the
118+
# auth config, so it is not needed here.
81119
callback_context.state[auth_config.credential_key] = (
82-
auth_config.exchanged_auth_credential
120+
_credential_without_client_secret(auth_config.exchanged_auth_credential)
83121
)

‎src/google/adk/auth/oauth2_credential_util.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,74 @@
3636
_TOKEN_REQUEST_TIMEOUT_SECONDS = 10
3737

3838

39+
def _credential_without_client_secret(
40+
credential: Optional[AuthCredential],
41+
) -> Optional[AuthCredential]:
42+
"""Returns a copy of credential with the OAuth2 client secret removed.
43+
44+
The client secret identifies the agent's OAuth2 client, not the end user, so
45+
it must not travel to the client or reach any store the client can read. Call
46+
sites that still need it for a token request re-attach it from the tool's own
47+
configuration, so dropping it here costs nothing.
48+
"""
49+
if credential is None:
50+
return None
51+
redacted = credential.model_copy(deep=True)
52+
if redacted.oauth2 is not None:
53+
redacted.oauth2.client_secret = None
54+
return redacted
55+
56+
57+
def _with_configured_client_secret(
58+
*,
59+
credential: Optional[AuthCredential],
60+
raw_credential: Optional[AuthCredential],
61+
) -> Optional[AuthCredential]:
62+
"""Returns credential with the configured OAuth2 client secret restored.
63+
64+
The inverse of `_credential_without_client_secret`: a credential read back
65+
from a store that holds no secret needs one again before a token exchange or
66+
refresh. Only the secret is put back, so the rest of the stored credential
67+
round trips untouched.
68+
"""
69+
if (
70+
raw_credential is None
71+
or raw_credential.oauth2 is None
72+
or credential is None
73+
or credential.oauth2 is None
74+
):
75+
return credential
76+
restored = credential.model_copy(deep=True)
77+
if restored.oauth2 is not None:
78+
restored.oauth2.client_secret = raw_credential.oauth2.client_secret
79+
return restored
80+
81+
82+
def _with_configured_client(
83+
*,
84+
credential: Optional[AuthCredential],
85+
raw_credential: Optional[AuthCredential],
86+
) -> Optional[AuthCredential]:
87+
"""Returns credential with the whole configured OAuth2 client restored.
88+
89+
For credentials that came back through the client, which must not be able to
90+
pick which OAuth2 client its token is exchanged for, so the client id is
91+
pinned to the tool's own configuration along with the secret.
92+
"""
93+
restored = _with_configured_client_secret(
94+
credential=credential, raw_credential=raw_credential
95+
)
96+
if (
97+
raw_credential is None
98+
or raw_credential.oauth2 is None
99+
or restored is None
100+
or restored.oauth2 is None
101+
):
102+
return restored
103+
restored.oauth2.client_id = raw_credential.oauth2.client_id
104+
return restored
105+
106+
39107
@experimental
40108
def create_oauth2_session(
41109
auth_scheme: AuthScheme,

0 commit comments

Comments
 (0)