Skip to content

Commit ffa1843

Browse files
doughaydenGWeale
authored andcommitted
fix(auth): strip redirect_uri from credential_key
Merge #5692 ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: #5691 This change adds `auth_credential.oauth2.redirect_uri = None` to the OAuth2 strip block at the three call sites where credential hashing happens. `redirect_uri` is deployment configuration (which callback URL the auth server should redirect to), not part of the credential identity, so it should be excluded from the hash just as access_token, refresh_token, expires_at, and the other transient OAuth2 fields already are. Without this change, a credential minted under one deployment URL cannot be retrieved when the deployment moves to another. ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. Three new tests, one per affected method. Each constructs two `AuthCredential` instances that differ only in `redirect_uri` and asserts the computed key is identical: - `tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py::test_credential_key_is_stable_across_redirect_uri` - `tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py::test_legacy_credential_key_is_stable_across_redirect_uri` - `tests/unittests/auth/test_auth_config.py::test_credential_key_is_stable_across_redirect_uri` ``` $ pytest tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py tests/unittests/auth/test_auth_config.py ======================== 14 passed, 8 warnings in 2.90s ======================== $ pytest tests/unittests/ ================ 5740 passed, 2340 warnings in 86.64s (0:01:26) ================ ``` **Manual End-to-End (E2E) Tests:** A self-contained Runner-based reproduction is at https://github.com/doughayden/adk-issue-examples/tree/main/05-redirect_uri_in_credential_hash. The agent definition (`agent.py`) wires up an `OpenAPIToolset` against a local OAuth2 test server, configured with one redirect_uri value. `main.py` constructs an `InMemoryRunner`, seeds a real (non-expired) OAuth2 credential into session state under a different redirect_uri's hash, and runs the agent. The `--apply-fix` flag monkey-patches the proposed fix to demonstrate the resolution end-to-end. Without the fix: ``` 🌤️ WeatherAssistant Agent — redirect_uri-in-hash repro ============================================================ Proposed fix applied: False STORED_REDIRECT_URI: http://localhost:8080/callback CURRENT_REDIRECT_URI: http://localhost:8081/callback Hash keys produced by ToolContextCredentialStore.get_credential_key: STORED → oauth2_55f666541ad22e39_oauth2_8ba0457897522d9d_existing_exchanged_credential CURRENT → oauth2_55f666541ad22e39_oauth2_ae16199243c358df_existing_exchanged_credential ❌ Keys differ — credentials minted under STORED are not retrievable. 👤 User: What's the weather in San Francisco? 🌤️ Weather Assistant event stream: [function_call] get_weather by WeatherAssistant [auth_event] adk_request_credential by WeatherAssistant [function_response] get_weather by WeatherAssistant [text] WeatherAssistant: 'It seems I need your authorization to access weather data. Could you please g...' Event counts: function_calls: 1 auth_events: 1 function_responses: 1 text_events: 1 ✅ Bug reproduced: agent emitted 1 adk_request_credential event(s) despite a valid seeded credential being present in state. ``` With the fix: ``` 🌤️ WeatherAssistant Agent — redirect_uri-in-hash repro ============================================================ Proposed fix applied: True STORED_REDIRECT_URI: http://localhost:8080/callback CURRENT_REDIRECT_URI: http://localhost:8081/callback Hash keys produced by ToolContextCredentialStore.get_credential_key: STORED → oauth2_55f666541ad22e39_oauth2_c2ad46dffd26cd87_existing_exchanged_credential CURRENT → oauth2_55f666541ad22e39_oauth2_c2ad46dffd26cd87_existing_exchanged_credential ✅ Keys match — fix is taking effect at the hash level. 👤 User: What's the weather in San Francisco? 🌤️ Weather Assistant event stream: [function_call] get_weather by WeatherAssistant [function_response] get_weather by WeatherAssistant [text] WeatherAssistant: 'The weather in San Francisco is Clear with a temperature of 30 degrees Celsiu...' Event counts: function_calls: 1 auth_events: 0 function_responses: 1 text_events: 1 ✅ Fix verified: tool call succeeded against the seeded credential without an adk_request_credential prompt. ``` ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ### Additional context **Scope:** The same strip block exists at three call sites and has the same gap at all three. This PR patches all three. Patching only the tool-level pair (`tool_auth_handler.py`) and leaving the framework-level path (`auth_tool.py:AuthConfig.get_credential_key`) would leave the bug reachable for any consumer that does not work around #5327 with `get_auth_config = lambda: None`. Patching only `AuthConfig.get_credential_key` and leaving the tool-level pair would leave the bug reachable on the standard tool-level credential lookup path. **Upgrade note:** The credential_key shape changes with this fix: `redirect_uri` is no longer included in the hash. OAuth credentials cached in existing session state under the pre-fix key shape become unreachable under the new key. Users should expect a one-time re-auth prompt on the first run after upgrading. Subsequent runs use the new key normally. **Related:** - #5327 (preemptive toolset auth) - #5328 (refresh request scope) - #5329 (refreshed credential persistence, fixed in 218ea76) - #5637 (tool-level auth termination) Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#5692 from doughayden:fix/credential-key-strip-redirect-uri 3be2a85 PiperOrigin-RevId: 941217122
1 parent 14a24f2 commit ffa1843

4 files changed

Lines changed: 130 additions & 24 deletions

File tree

src/google/adk/auth/auth_tool.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,16 @@ def get_credential_key(self):
121121
auth_credential.model_extra.clear()
122122
if auth_credential and auth_credential.oauth2:
123123
auth_credential = auth_credential.model_copy(deep=True)
124-
auth_credential.oauth2.auth_uri = None
125-
auth_credential.oauth2.state = None
126-
auth_credential.oauth2.auth_response_uri = None
127-
auth_credential.oauth2.auth_code = None
128-
auth_credential.oauth2.access_token = None
129-
auth_credential.oauth2.refresh_token = None
130-
auth_credential.oauth2.expires_at = None
131-
auth_credential.oauth2.expires_in = None
124+
if auth_credential.oauth2:
125+
auth_credential.oauth2.auth_uri = None
126+
auth_credential.oauth2.state = None
127+
auth_credential.oauth2.auth_response_uri = None
128+
auth_credential.oauth2.auth_code = None
129+
auth_credential.oauth2.access_token = None
130+
auth_credential.oauth2.refresh_token = None
131+
auth_credential.oauth2.expires_at = None
132+
auth_credential.oauth2.expires_in = None
133+
auth_credential.oauth2.redirect_uri = None
132134
credential_name = (
133135
f"{auth_credential.auth_type.value}_{_stable_model_digest(auth_credential)}"
134136
if auth_credential

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

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,16 @@ def _get_legacy_credential_key(
6262
) -> str:
6363
if auth_credential and auth_credential.oauth2:
6464
auth_credential = auth_credential.model_copy(deep=True)
65-
auth_credential.oauth2.auth_uri = None
66-
auth_credential.oauth2.state = None
67-
auth_credential.oauth2.auth_response_uri = None
68-
auth_credential.oauth2.auth_code = None
69-
auth_credential.oauth2.access_token = None
70-
auth_credential.oauth2.refresh_token = None
71-
auth_credential.oauth2.expires_at = None
72-
auth_credential.oauth2.expires_in = None
65+
if auth_credential.oauth2:
66+
auth_credential.oauth2.auth_uri = None
67+
auth_credential.oauth2.state = None
68+
auth_credential.oauth2.auth_response_uri = None
69+
auth_credential.oauth2.auth_code = None
70+
auth_credential.oauth2.access_token = None
71+
auth_credential.oauth2.refresh_token = None
72+
auth_credential.oauth2.expires_at = None
73+
auth_credential.oauth2.expires_in = None
74+
auth_credential.oauth2.redirect_uri = None
7375
scheme_name = (
7476
f"{auth_scheme.type_.name}_{self._legacy_stable_digest(auth_scheme.model_dump_json())}"
7577
if auth_scheme
@@ -91,14 +93,16 @@ def get_credential_key(
9193

9294
if auth_credential and auth_credential.oauth2:
9395
auth_credential = auth_credential.model_copy(deep=True)
94-
auth_credential.oauth2.auth_uri = None
95-
auth_credential.oauth2.state = None
96-
auth_credential.oauth2.auth_response_uri = None
97-
auth_credential.oauth2.auth_code = None
98-
auth_credential.oauth2.access_token = None
99-
auth_credential.oauth2.refresh_token = None
100-
auth_credential.oauth2.expires_at = None
101-
auth_credential.oauth2.expires_in = None
96+
if auth_credential.oauth2:
97+
auth_credential.oauth2.auth_uri = None
98+
auth_credential.oauth2.state = None
99+
auth_credential.oauth2.auth_response_uri = None
100+
auth_credential.oauth2.auth_code = None
101+
auth_credential.oauth2.access_token = None
102+
auth_credential.oauth2.refresh_token = None
103+
auth_credential.oauth2.expires_at = None
104+
auth_credential.oauth2.expires_in = None
105+
auth_credential.oauth2.redirect_uri = None
102106
scheme_name = (
103107
f"{auth_scheme.type_.name}_{_stable_model_digest(auth_scheme)}"
104108
if auth_scheme

tests/unittests/auth/test_auth_config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,41 @@ def test_credential_key_with_custom_auth_scheme():
176176
key = custom_config.credential_key
177177
assert key.startswith("adk_mock_custom_type_")
178178
assert len(key) > len("adk_mock_custom_type_")
179+
180+
181+
def test_credential_key_is_stable_across_redirect_uri(oauth2_auth_scheme):
182+
"""AuthConfig.credential_key should be invariant under redirect_uri changes.
183+
184+
redirect_uri is deployment configuration (which callback URL the auth
185+
server should redirect to), not part of the credential identity. Two
186+
AuthConfig instances built from credentials that share the same client_id,
187+
client_secret, and scopes but differ only in redirect_uri should produce
188+
the same credential_key.
189+
"""
190+
credential_local = AuthCredential(
191+
auth_type=AuthCredentialTypes.OAUTH2,
192+
oauth2=OAuth2Auth(
193+
client_id="client",
194+
client_secret="secret",
195+
redirect_uri="http://localhost:8001/oauth2callback",
196+
),
197+
)
198+
credential_deployed = AuthCredential(
199+
auth_type=AuthCredentialTypes.OAUTH2,
200+
oauth2=OAuth2Auth(
201+
client_id="client",
202+
client_secret="secret",
203+
redirect_uri="https://deployed.example.com/oauth2callback",
204+
),
205+
)
206+
207+
config_local = AuthConfig(
208+
auth_scheme=oauth2_auth_scheme,
209+
raw_auth_credential=credential_local,
210+
)
211+
config_deployed = AuthConfig(
212+
auth_scheme=oauth2_auth_scheme,
213+
raw_auth_credential=credential_deployed,
214+
)
215+
216+
assert config_local.credential_key == config_deployed.credential_key

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,65 @@ async def test_refreshed_credential_is_persisted_to_store(
353353
assert persisted is not None
354354
assert persisted.oauth2.access_token == 'new_access_token'
355355
assert persisted.oauth2.refresh_token == 'new_refresh_token'
356+
357+
358+
def test_credential_key_is_stable_across_redirect_uri():
359+
"""get_credential_key should be invariant under redirect_uri changes.
360+
361+
redirect_uri is deployment configuration (which callback URL the auth
362+
server should redirect to), not part of the credential identity. Two
363+
AuthCredential instances that share the same client_id, client_secret,
364+
and scopes but differ only in redirect_uri should produce the same key.
365+
"""
366+
scheme, _ = get_mock_openid_scheme_credential()
367+
credential_local = AuthCredential(
368+
auth_type=AuthCredentialTypes.OAUTH2,
369+
oauth2=OAuth2Auth(
370+
client_id='client',
371+
client_secret='secret',
372+
redirect_uri='http://localhost:8001/oauth2callback',
373+
),
374+
)
375+
credential_deployed = AuthCredential(
376+
auth_type=AuthCredentialTypes.OAUTH2,
377+
oauth2=OAuth2Auth(
378+
client_id='client',
379+
client_secret='secret',
380+
redirect_uri='https://deployed.example.com/oauth2callback',
381+
),
382+
)
383+
store = ToolContextCredentialStore(tool_context=create_mock_tool_context())
384+
385+
assert store.get_credential_key(
386+
scheme, credential_local
387+
) == store.get_credential_key(scheme, credential_deployed)
388+
389+
390+
def test_legacy_credential_key_is_stable_across_redirect_uri():
391+
"""_get_legacy_credential_key should be invariant under redirect_uri changes.
392+
393+
The same redirect_uri-strip behavior must apply to the legacy key path so
394+
that already-stored credentials remain findable after the fix.
395+
"""
396+
scheme, _ = get_mock_openid_scheme_credential()
397+
credential_local = AuthCredential(
398+
auth_type=AuthCredentialTypes.OAUTH2,
399+
oauth2=OAuth2Auth(
400+
client_id='client',
401+
client_secret='secret',
402+
redirect_uri='http://localhost:8001/oauth2callback',
403+
),
404+
)
405+
credential_deployed = AuthCredential(
406+
auth_type=AuthCredentialTypes.OAUTH2,
407+
oauth2=OAuth2Auth(
408+
client_id='client',
409+
client_secret='secret',
410+
redirect_uri='https://deployed.example.com/oauth2callback',
411+
),
412+
)
413+
store = ToolContextCredentialStore(tool_context=create_mock_tool_context())
414+
415+
assert store._get_legacy_credential_key(
416+
scheme, credential_local
417+
) == store._get_legacy_credential_key(scheme, credential_deployed)

0 commit comments

Comments
 (0)