Skip to content

OAuth2 token refresh fails for providers that reject scope parameter (e.g. Salesforce) #5328

Closed

Description

@doughayden

🔴 Required Information

Describe the Bug:

OAuth2CredentialRefresher.refresh() transitively includes the scope parameter in refresh token requests because create_oauth2_session() passes scopes to authlib's OAuth2Session constructor. Salesforce rejects refresh requests that include a scope parameter, causing all automatic token refreshes to fail silently for Salesforce-backed toolsets. Other providers with strict RFC 6749 §6 interpretations may be similarly affected.

Per RFC 6749 §6, scope is OPTIONAL in refresh requests — when omitted, providers treat it as equal to the originally-granted scope. Since sending scope on refresh provides no functional benefit (it can only narrow, not broaden the granted scope) and some providers actively reject it, ADK should default to omitting it for maximum compatibility.

Steps to Reproduce:

  1. Configure an OpenAPIToolset with OAuth2 authorizationCode flow and one or more scopes. Use a provider that rejects scope in refresh requests (e.g. Salesforce).
  2. Complete the initial OAuth authorization flow to obtain a valid access_token + refresh_token.
  3. Wait for the access_token to expire (or manually expire it by setting expires_at=<past-timestamp> in session state).
  4. Invoke a tool — ADK's ToolAuthHandler._get_existing_credential() calls OAuth2CredentialRefresher.refresh().
  5. Observe: refresh fails with invalid_request: scope parameter not supported (Salesforce).

Expected Behavior:

The refresh should succeed — ADK should not send scope in refresh token requests by default, since the authorized scopes are bound at initial exchange and cannot be changed on most providers.

Observed Behavior:

Refresh fails with Failed to refresh OAuth2 tokens: invalid_request: scope parameter not supported. The error is caught and logged inside OAuth2CredentialRefresher.refresh(), but the original stale credential is silently returned. The tool then executes with the expired access_token and receives a 401.

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

Logs:

Failed to refresh OAuth2 tokens: invalid_request: scope parameter not supported

Additional Context:

Root cause — create_oauth2_session in google/adk/auth/oauth2_credential_util.py:

return (
    OAuth2Session(
        auth_credential.oauth2.client_id,
        auth_credential.oauth2.client_secret,
        scope=" ".join(scopes),      # ← unconditionally passed
        redirect_uri=auth_credential.oauth2.redirect_uri,
        state=auth_credential.oauth2.state,
        token_endpoint_auth_method=auth_credential.oauth2.token_endpoint_auth_method,
    ),
    token_endpoint,
)

authlib's OAuth2Session.refresh_token() auto-includes self.scope in the refresh request body when scope is set on the session:

# authlib source
if "scope" not in kwargs and self.scope:
    kwargs["scope"] = self.scope

Proposed fix — don't set scope on the OAuth2Session constructor. Scopes are only used for authorization redirect URLs, which are built separately via client.create_authorization_url():

return (
    OAuth2Session(
        auth_credential.oauth2.client_id,
        auth_credential.oauth2.client_secret,
        redirect_uri=auth_credential.oauth2.redirect_uri,
        state=auth_credential.oauth2.state,
        token_endpoint_auth_method=auth_credential.oauth2.token_endpoint_auth_method,
    ),
    token_endpoint,
)

Secondary issue — workaround hazard: oauth2_credential_refresher.py does from google.adk.auth.oauth2_credential_util import create_oauth2_session. This creates a local binding in the refresher module. Consumers attempting a monkey-patch workaround must patch the refresher module's binding, not the source module — otherwise the patch appears to succeed but has no effect. Consider changing to import oauth2_credential_util + oauth2_credential_util.create_oauth2_session(...) inside refresh() to make the reference late-bound and patchable.

Impact: Any ADK user integrating with Salesforce via OpenAPIToolset will experience silent refresh failures. The user symptom is a full OAuth re-authorization flow on every access_token expiry (typically every 1-2 hours). Other OAuth providers with strict RFC 6749 §6 interpretations of the scope parameter may be similarly affected.

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. The test server is run with STRICT_SCOPE_REJECTION=1 to mimic Salesforce's refresh behavior. 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/02-scope_in_refresh

Expected output — without --apply-fix the tool call's refresh fails (server rejects scope in the refresh body); ADK falls through to _request_credential and the agent asks for re-auth. With --apply-fix the refresh succeeds and the tool returns weather data.

Workaround applied in our project:

from google.adk.auth.refresher import oauth2_credential_refresher

_original = oauth2_credential_refresher.create_oauth2_session

def _no_scope(auth_scheme, auth_credential):
    session, endpoint = _original(auth_scheme, auth_credential)
    if session is not None:
        session.scope = None
    return session, endpoint

oauth2_credential_refresher.create_oauth2_session = _no_scope

How often has this issue occurred?: Always (100%) — reproduces on every access_token expiry for affected providers.

Activity

  1. added
    auth[Component] This issue is related to authorization
    on Apr 14, 2026
  2. added theissue type on Apr 14, 2026
  3. klateefa commented on Apr 17, 2026

    @klateefa

    @doughayden This issue happens because scope is included in the refresh request. Some providers like Salesforce reject this, so the refresh fails.As per OAuth, scope is optional during refresh. If not provided, the server uses the already approved scope.Do not set scope in OAuth2Session. Use it only during authorization.Can you try this and let me know if it works for you?

  4. doughayden commented on Apr 17, 2026

    @doughayden
    ContributorAuthor

    Thanks for the triage @klateefa, and big thanks to @voidborne-d for picking this up in #5352. I reviewed the PR, and it addresses the root cause. The fix mechanism differs from what I proposed (passing scope="" to client.refresh_token() instead of stripping it off the OAuth2Session constructor), but the net effect on the POST body is the same. authlib's "scope" not in kwargs check short-circuits the injection, and prepare_token_request drops the empty-string kwarg before the wire. We'll remove our monkey-patch once it lands.

    One nit on the PR if it's not too late. The test test_refresh_omits_scope_from_request asserts the kwarg signature (scope=""), but not that the outgoing POST body actually excludes scope=. If authlib ever switches the "scope" not in kwargs check to a truthiness check, the test would still pass while the bug silently came back. Mocking one layer lower, like session.request, and asserting the body directly would make the regression test independent of authlib's internals. Not a blocker, just considering robustness.

    Also, the issue body above has a runnable reproduction, the proposed fix, and the monkey-patch we're using now, in case any of it helps close the companion issues I filed alongside this one: #5327 (preemptive toolset auth triggering OAuth on every invocation) and #5329 (refreshed credentials not persisted to the credential store).

  5. doughayden commented on May 23, 2026

    @doughayden
    ContributorAuthor

    @klateefa following up on this issue. Reading your guidance:

    Do not set scope in OAuth2Session. Use it only during authorization.

    Could you clarify how to do this within ADK? Following the public authenticated tool example, we set flows.authorizationCode.scopes in the auth_scheme parameter during construction of the OpenAPIToolset instance. It feeds every OAuth2Session call site in adk/auth/: auth URL (auth_handler.py:191), exchange (exchanger/oauth2_credential_exchanger.py:145, :188), and refresh (refresher/oauth2_credential_refresher.py:106). This puts the codebase in conflict:

    • We need to set scopes in auth_scheme so the OAuth2Session authlib client can set the scope= query parameter in auth URL construction. RFC 6749 section 3.3 allows providers to fail the auth request for invalid scope when the parameter is missing.
    • I don't see a way today in the ADK codebase to split scopes out of the auth_scheme in the exchange or refresh code paths for providers that reject those requests containing scope=.
  6. doughayden commented on May 29, 2026

    @doughayden
    ContributorAuthor

    Resolved on main by 6ce4b87 (#5874). Closing as fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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