Repository navigation
Preemptive toolset auth triggers OAuth redirect on every agent invocation #5327
Description
🔴 Required Information
Describe the Bug:
_resolve_toolset_auth in base_llm_flow.py runs before every agent invocation and calls get_auth_config() on each toolset. If the toolset returns an AuthConfig, the framework checks for credentials before the LLM has decided whether any tool call is needed. When the framework-level credential lookup fails — which it always does for tool-level-authenticated credentials because the two paths use different key formats — this triggers a full OAuth redirect flow on every message, including simple greetings that will never invoke any tool.
Steps to Reproduce:
- Register an
OpenAPIToolset(orApplicationIntegrationToolset) on anLlmAgentwith OAuth2authorizationCodeflow. - Send any message to the agent — e.g.
"hey". - Observe: an
adk_request_credentialevent fires before the LLM response, producing an OAuth redirect URL. - Complete the OAuth flow; send another message (tool-invoking or not).
- Observe: the redirect fires again, indefinitely on every subsequent message.
Expected Behavior:
OAuth should only be triggered when the LLM actually invokes a tool that requires authentication. Simple greetings or non-tool-invoking messages should proceed without any auth flow. After a successful OAuth exchange, the credential should be found and reused by subsequent tool invocations.
Observed Behavior:
OAuth redirect fires on every message regardless of whether any tool would be invoked. Even after a successful authorization, the next message triggers OAuth again because the framework-level credential lookup can't find the credential that was stored by the tool-level path under a different key format.
Environment Details:
- ADK Library 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 (irrelevant — this is in the auth path, not the model path)
🟡 Optional Information
Additional Context:
Root cause — _resolve_toolset_auth in google/adk/flows/llm_flows/base_llm_flow.py (simplified for clarity; the actual implementation adds a try/except ValueError around get_auth_credential and constructs toolset_id from the toolset class name):
for tool_union in agent.tools:
if not isinstance(tool_union, BaseToolset):
continue
auth_config = tool_union.get_auth_config()
if not auth_config:
continue
credential = await CredentialManager(auth_config).get_auth_credential(
callback_context
)
if credential:
auth_config.exchanged_auth_credential = credential
else:
# triggers OAuth redirect — regardless of whether the LLM
# is going to invoke any tool from this toolset
pending_auth_requests[toolset_id] = auth_configThe framework checks unconditionally on every invocation — it doesn't know whether the upcoming LLM response will use any tool from the toolset.
Secondary issue — key format mismatch makes the preemptive check worse:
The preemptive check would be merely inefficient if it could actually find credentials stored by successful tool invocations. But ADK has two independent credential storage paths that use different key formats for the same logical credential:
| Path | Where | Key format | Example |
|---|---|---|---|
| Framework-level | AuthConfig.credential_key in auth_tool.py |
adk_{scheme}_{credential} |
adk_oauth2_a1b2c3d4_OAUTH2_e5f6g7h8 |
| Tool-level | ToolContextCredentialStore.get_credential_key() in tool_auth_handler.py |
{scheme}_{credential}_existing_exchanged_credential |
oauth2_a1b2c3d4_OAUTH2_e5f6g7h8_existing_exchanged_credential |
Both use the same _stable_model_digest() for the middle segments. The wrapper differs.
The failure flow:
- First message →
_resolve_toolset_auth→load_credential(key="adk_...")→ not found → triggers OAuth - User authorizes → token exchange via
ToolAuthHandler→ stored under{...}_existing_exchanged_credential - Next message →
_resolve_toolset_auth→load_credential(key="adk_...")→ still not found (credential exists under the tool-level key) → triggers OAuth again, indefinitely
Passing a custom credential_service (e.g. SessionStateCredentialService) to the Runner doesn't help — ToolContextCredentialStore bypasses the configured credential_service entirely and writes directly to tool_context.state.
The ToolContextCredentialStore source contains a TODO that acknowledges architectural tension:
# TODO try not to use session state, this looks a hacky way, depend on
# session implementation, we don't want session to persist the token,
# meanwhile we want the token shared across runs.Proposed fix (preferred): Defer toolset auth until a tool is actually invoked. ToolAuthHandler already handles tool-level auth on demand during RestApiTool.call() and is fully self-contained — it doesn't depend on framework-level pre-population. Removing the preemptive check from _resolve_toolset_auth would:
- Fix the preemptive OAuth trigger on non-tool messages
- Eliminate per-invocation overhead
- Make the framework-level credential store optional in the OAuth path — tools authenticate themselves and use their own store
- Make the key-format mismatch a non-issue in practice
Alternative (if the framework-level path is intentional): Unify the key formats between AuthConfig.credential_key and ToolContextCredentialStore.get_credential_key() so credentials stored by one path are findable by the other:
class ToolContextCredentialStore:
def get_credential_key(self, auth_scheme, auth_credential) -> str:
auth_config = AuthConfig(
auth_scheme=auth_scheme,
raw_auth_credential=auth_credential,
)
if auth_config.credential_key:
return auth_config.credential_key
# Legacy fallback
return f"{scheme_name}_{credential_name}_existing_exchanged_credential"Impact: Users see OAuth redirects on every single message until a workaround is applied. Messages that don't need tools (greetings, clarifying questions, general chitchat) all trigger auth flows, making OAuth-enabled agents effectively unusable without intervention.
Companion issues (filed together):
- OAuth2 token refresh fails for providers that reject scope parameter (e.g. Salesforce) #5328 — OAuth2 token refresh fails for providers that reject
scopeparameter ToolAuthHandler._get_existing_credentialrefreshes OAuth2 credentials in memory but doesn't persist them #5329 — Refreshed OAuth2 credentials are not persisted to the credential store
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 — without --apply-fix a non-tool prompt ("Hi! What can you do?") emits an adk_request_credential function call before the LLM runs. With --apply-fix the LLM responds normally.
Workaround applied in our project — disable the preemptive check on the toolset instance:
salesforce_toolset.get_auth_config = lambda: None # type: ignore[method-assign]This disables the framework-level preemptive check while leaving tool-level auth intact via ToolAuthHandler. OAuth only fires when the LLM actually invokes a tool.
Why this is safe:
get_tools()does not useexchanged_auth_credential— it returns cachedRestApiToolinstances filtered bytool_filter.- Each
RestApiTool.call()runs its ownToolAuthHandler.prepare_auth_credentials()which independently checksToolContextCredentialStore, triggers OAuth on demand, and stores credentials in session state. - The tool-level auth path is fully self-contained — it doesn't depend on framework-level pre-population.
For projects that can't disable the preemptive check, a custom BaseCredentialService can bridge both key formats by checking the framework key first then falling back to scanning state for keys ending with _existing_exchanged_credential, injected into the Runner as credential_service.
How often has this issue occurred?: Always (100%) — reproduces on every message for any agent with an OAuth2 toolset.
Activity
abehsu-mu commentedon Apr 15, 2026
We are noticing this issue too. The credential key and the key store within the credential service are not aligned.
patrasap0908 commentedon Apr 16, 2026
Okay for me to take this one?
patrasap0908 commentedon Apr 16, 2026
Existing tests confirm this too - https://github.com/google/adk-python/blob/main/tests/unittests/auth/test_toolset_auth.py#L206-L214.
doughayden commentedon Apr 17, 2026
Thanks to @abehsu-mu for confirmation and to @patrasap0908 for picking this up in #5353. I reviewed the PR, and it addresses the root cause cleanly. The fix matches the "preferred" option from the issue body, deferring toolset auth to tool invocation time via the existing
ToolAuthHandler. We'll remove ourget_auth_config = lambda: Noneworkaround when it lands.One minor note for the PR thread, not a blocker:
TOOLSET_AUTH_CREDENTIAL_ID_PREFIXinauth_preprocessor.py(defined at line 37, used as a filter at line 119) is now effectively dead code since nothing emits events with that prefix anymore.patrasap0908 commentedon Apr 18, 2026
Thanks for reviewing the PR and for your pointer, @doughayden. I've cleaned up
auth_preprocessor.pyas well.sanketpatil06 commentedon Apr 20, 2026
Hi @doughayden,
Thank you for reporting this and for the detailed write-up. We have reproduced the issue on our end and observed the same behavior you described.
Additionally, @patrasap0908, thank you for raising PR #5353. Our team is currently reviewing it, and if everything looks good, we will merge it.
We appreciate both of your contributions.
doughayden commentedon May 8, 2026
Hi @sanketpatil06, gentle bump on this issue to inquire why the related pr #5353 from @patrasap0908 was closed?
fix(auth): remove unneeded OAuth flows
patrasap0908 commentedon May 13, 2026
Hi @doughayden, a fix will be rolled out for this in the next couple of days (mostly by tomorrow).
fix(auth): remove unneeded OAuth flows
patrasap0908 commentedon May 14, 2026
The fix is now in but it'll take a couple of weeks to be released due to I/O coming up - c35a579.
patrasap0908 commentedon May 20, 2026
This issue was fixed in https://github.com/google/adk-python/releases/tag/v1.34.0.
sanketpatil06 commentedon May 22, 2026
Hi @doughayden , Could you please confirm if the issue is resolved on your end?
doughayden commentedon May 23, 2026
Hi @sanketpatil06. Yes, the fix resolves the issue for me. Thank you for following up!
fix(flows): terminate invocation at tool-level EUC
fix(auth): strip redirect_uri from credential_key
fix(auth): remove unneeded OAuth flows
fix(flows): terminate invocation at tool-level EUC
fix(auth): strip redirect_uri from credential_key