Repository navigation
Description
π΄ Required Information
Describe the Bug:
ToolAuthHandler._get_existing_credential() calls OAuth2CredentialRefresher.refresh() to refresh expired OAuth2 credentials, but the refreshed credential is only updated in memory β it's never written back to the credential store. As a result, the next tool invocation reads the stale pre-refresh credential from state, attempts another refresh using the now-rotated refresh_token, fails (because the provider already invalidated the old refresh_token), and triggers a full OAuth re-authorization flow.
The class already has a _store_credential() method that does exactly what's needed β it's just not called after a successful refresh.
Likely cause of the oversight:
OAuth2CredentialRefresher.refresh() calls update_credential_with_tokens(), which mutates the oauth2 sub-object of the passed AuthCredential in place (auth_credential.oauth2.access_token = tokens.get("access_token"), etc.). A reader could reasonably assume that because the refresh mutates in place, the credential store already reflects the new tokens. That assumption holds for a store that holds object references directly β but not for the actual implementations.
ToolContextCredentialStore.store_credential() serializes via auth_credential.model_dump(exclude_none=True) and writes the resulting dict to tool_context.state. get_credential() reconstructs a fresh Pydantic model from that dict on each call. The in-memory object returned by get_credential() is disconnected from the stored dict β mutations to the object don't propagate back. So the next invocation deserializes the original pre-refresh dict and gets the stale tokens.
The fix is to explicitly re-serialize by calling _store_credential() after the refresh.
Steps to Reproduce:
- Configure an
OpenAPIToolsetwith OAuth2authorizationCodeflow, using a provider that rotates refresh_tokens on refresh (Salesforce, many OIDC providers). - Use a persistent session backing store (
DatabaseSessionServiceor similar). - Complete the initial OAuth authorization flow.
- Expire the access_token (naturally or by setting
expires_at=<past>in session state). - Invoke a tool β refresh fires, returns a new access_token and rotated refresh_token. The tool executes successfully.
- Send another message that triggers a tool call.
- Observe: a full OAuth redirect is triggered, even though the refresh just succeeded moments earlier.
Expected Behavior:
After a successful refresh, the new access_token and refresh_token should be persisted to the credential store. The next tool invocation should use the refreshed credential β no re-authorization should be required.
Observed Behavior:
The first post-expiry invocation works correctly (in-memory refresh succeeded). The second invocation fails because the credential in state still has the stale pre-refresh tokens, and attempting to refresh with a rotated refresh_token causes the provider to reject the request. The framework then calls _request_credential() and triggers a full OAuth redirect.
Environment Details:
- ADK Version:
google-adk 1.28.0 - Desktop OS: macOS (reproduced), Linux (expected to affect all platforms)
- Python Version: Python 3.13
Model Information:
- Are you using LiteLLM: No
- Which model is being used: gemini-2.5-flash (this is in the auth path, not the model path)
π‘ Optional Information
Additional Context:
Root cause in _get_existing_credential in google/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py:
async def _get_existing_credential(
self,
) -> Optional[AuthCredential]:
"""Checks for and returns an existing, exchanged credential."""
if self.credential_store:
existing_credential = self.credential_store.get_credential(
self.auth_scheme, self.auth_credential
)
if existing_credential:
if existing_credential.oauth2:
refresher = OAuth2CredentialRefresher()
if await refresher.is_refresh_needed(existing_credential):
existing_credential = await refresher.refresh(
existing_credential, self.auth_scheme
)
# β refreshed credential is never written back to store
return existing_credential
return NoneProposed fix β add one line after the refresh call:
async def _get_existing_credential(
self,
) -> Optional[AuthCredential]:
if self.credential_store:
existing_credential = self.credential_store.get_credential(
self.auth_scheme, self.auth_credential
)
if existing_credential:
if existing_credential.oauth2:
refresher = OAuth2CredentialRefresher()
if await refresher.is_refresh_needed(existing_credential):
existing_credential = await refresher.refresh(
existing_credential, self.auth_scheme
)
self._store_credential(existing_credential) # β persist refreshed credential
return existing_credential
return NoneImpact: Any ADK user with an OAuth provider that rotates refresh_tokens (standard security practice) will see a full re-authorization prompt on the message after every access_token expiry. This is additionally misleading because the first post-expiry invocation works correctly (the in-memory refresh succeeded), so the failure mode shows up one step removed from the root cause.
Companion issues (filed together):
- Preemptive toolset auth triggers OAuth redirect on every agent invocationΒ #5327 β Preemptive toolset auth triggers OAuth on every agent invocation
- OAuth2 token refresh fails for providers that reject scope parameter (e.g. Salesforce)Β #5328 β OAuth2 token refresh fails for providers that reject
scopeparameter
Minimal Reproduction Code:
Complete runnable reproduction β mirrors the contributing/samples/oauth2_client_credentials layout (agent.py + main.py + oauth2_test_server.py + README.md), adapted to use the authorization_code flow. A --apply-fix CLI flag monkey-patches the proposed fix so the same script demonstrates both the bug and its resolution:
Expected output β the script sends two weather queries in the same session. Without --apply-fix, the first succeeds but the second triggers a full re-auth because the refreshed credential was never persisted to the store. With --apply-fix, both succeed.
Workaround applied in our project β monkey-patch ToolAuthHandler._get_existing_credential:
from google.adk.tools.openapi_tool.openapi_spec_parser import tool_auth_handler
from google.adk.auth.refresher import oauth2_credential_refresher
async def _get_existing_credential_and_persist(self):
if not self.credential_store:
return None
existing = self.credential_store.get_credential(
self.auth_scheme, self.auth_credential
)
if not existing:
return None
if existing.oauth2:
refresher = oauth2_credential_refresher.OAuth2CredentialRefresher()
if await refresher.is_refresh_needed(existing):
refreshed = await refresher.refresh(existing, self.auth_scheme)
self._store_credential(refreshed)
return refreshed
return existing
tool_auth_handler.ToolAuthHandler._get_existing_credential = _get_existing_credential_and_persistHow often has this issue occurred?: Always (100%) β reproduces on every access_token expiry for any provider that rotates refresh_tokens.
Activity
llalitkumarrr commentedon Apr 16, 2026
Hello @doughayden ,
Thank you for such a detailed report and for proposing a fix. We noticed there is a PR linked to this issue that aligns with your suggestion. If you have time could you please take a look at it and share your feedback?
Meanwhile our team will review the PR and merge it once it passes all checks and is approved by reviewer.
doughayden commentedon Apr 16, 2026
@llalitkumarrr thanks for your reply!
Yes, PR #5350 matches the proposed fix exactly.
self._store_credential(existing_credential)after the refresh in_get_existing_credential, same line, same location.The added regression test (
test_refreshed_credential_is_persisted_to_store) follows the existing pattern intest_tool_auth_handler.py(mirrorstest_openid_connect_existing_oauth2_token_refresh) and exercises the fix behaviorally. It uses a realToolContextCredentialStoreand reads back viaget_credentialto verify the rotatedaccess_tokenandrefresh_tokenare persisted, so it would fail without the fix.Looks good to merge from my side.
llalitkumarrr commentedon Apr 21, 2026
Hello @doughayden ,
Thank you for sharing your inputs.
Hello @rohityan could you please take a look at linked PR?
adk-bot commentedon May 1, 2026
This issue has been automatically marked as stale because it has not had recent activity for 7 days after a maintainer requested clarification. It will be closed if no further activity occurs within 7 days.
doughayden commentedon May 4, 2026
@llalitkumarrr gentle bump to ensure this doesn't go stale and close, ty.
fix(auth): persist refreshed OAuth2 credentials to store
fix(auth): strip redirect_uri from credential_key
fix(auth): persist refreshed OAuth2 credentials to store
fix(auth): strip redirect_uri from credential_key