π΄ 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
OpenAPIToolset with OAuth2 authorizationCode flow, using a provider that rotates refresh_tokens on refresh (Salesforce, many OIDC providers).
- Use a persistent session backing store (
DatabaseSessionService or 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 None
Proposed 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 None
Impact: 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):
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:
https://github.com/doughayden/adk-issue-examples/tree/2f454e73c2f1885ebe9be61125e02800cb2164b3/03-refresh_not_persisted
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_persist
How often has this issue occurred?: Always (100%) β reproduces on every access_token expiry for any provider that rotates refresh_tokens.
π΄ Required Information
Describe the Bug:
ToolAuthHandler._get_existing_credential()callsOAuth2CredentialRefresher.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()callsupdate_credential_with_tokens(), which mutates theoauth2sub-object of the passedAuthCredentialin 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 viaauth_credential.model_dump(exclude_none=True)and writes the resulting dict totool_context.state.get_credential()reconstructs a fresh Pydantic model from that dict on each call. The in-memory object returned byget_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:
OpenAPIToolsetwith OAuth2authorizationCodeflow, using a provider that rotates refresh_tokens on refresh (Salesforce, many OIDC providers).DatabaseSessionServiceor similar).expires_at=<past>in session state).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:
google-adk 1.28.0Model Information:
π‘ Optional Information
Additional Context:
Root cause in
_get_existing_credentialingoogle/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py:Proposed fix β add one line after the refresh call:
Impact: 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):
scopeparameterMinimal Reproduction Code:
Complete runnable reproduction β mirrors the
contributing/samples/oauth2_client_credentialslayout (agent.py+main.py+oauth2_test_server.py+README.md), adapted to use theauthorization_codeflow. A--apply-fixCLI flag monkey-patches the proposed fix so the same script demonstrates both the bug and its resolution:https://github.com/doughayden/adk-issue-examples/tree/2f454e73c2f1885ebe9be61125e02800cb2164b3/03-refresh_not_persisted
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:How often has this issue occurred?: Always (100%) β reproduces on every access_token expiry for any provider that rotates refresh_tokens.