Skip to content

Preemptive toolset auth triggers OAuth redirect on every agent invocation #5327

@doughayden

Description

@doughayden

🔴 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:

  1. Register an OpenAPIToolset (or ApplicationIntegrationToolset) on an LlmAgent with OAuth2 authorizationCode flow.
  2. Send any message to the agent — e.g. "hey".
  3. Observe: an adk_request_credential event fires before the LLM response, producing an OAuth redirect URL.
  4. Complete the OAuth flow; send another message (tool-invoking or not).
  5. 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_config

The 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:

  1. First message → _resolve_toolset_authload_credential(key="adk_...") → not found → triggers OAuth
  2. User authorizes → token exchange via ToolAuthHandler → stored under {...}_existing_exchanged_credential
  3. Next message → _resolve_toolset_authload_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):

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/01-preemptive_toolset_auth

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:

  1. get_tools() does not use exchanged_auth_credential — it returns cached RestApiTool instances filtered by tool_filter.
  2. Each RestApiTool.call() runs its own ToolAuthHandler.prepare_auth_credentials() which independently checks ToolContextCredentialStore, triggers OAuth on demand, and stores credentials in session state.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    auth[Component] This issue is related to authorization

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions