Skip to content

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

@doughayden

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.

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