Repository navigation
OAuth2 token refresh fails for providers that reject scope parameter (e.g. Salesforce) #5328
Description
🔴 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:
- Configure an
OpenAPIToolsetwith OAuth2authorizationCodeflow and one or more scopes. Use a provider that rejectsscopein refresh requests (e.g. Salesforce). - Complete the initial OAuth authorization flow to obtain a valid access_token + refresh_token.
- Wait for the access_token to expire (or manually expire it by setting
expires_at=<past-timestamp>in session state). - Invoke a tool — ADK's
ToolAuthHandler._get_existing_credential()callsOAuth2CredentialRefresher.refresh(). - 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.scopeProposed 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):
- Preemptive toolset auth triggers OAuth redirect on every agent invocation #5327 — Preemptive toolset auth triggers OAuth on every agent invocation
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. 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:
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_scopeHow often has this issue occurred?: Always (100%) — reproduces on every access_token expiry for affected providers.
Activity
klateefa commentedon Apr 17, 2026
@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?
doughayden commentedon Apr 17, 2026
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=""toclient.refresh_token()instead of stripping it off theOAuth2Sessionconstructor), but the net effect on the POST body is the same. authlib's"scope" not in kwargscheck short-circuits the injection, andprepare_token_requestdrops 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_requestasserts the kwarg signature (scope=""), but not that the outgoing POST body actually excludesscope=. If authlib ever switches the"scope" not in kwargscheck to a truthiness check, the test would still pass while the bug silently came back. Mocking one layer lower, likesession.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).
doughayden commentedon May 23, 2026
@klateefa following up on this issue. Reading your guidance:
Could you clarify how to do this within ADK? Following the public authenticated tool example, we set
flows.authorizationCode.scopesin theauth_schemeparameter during construction of theOpenAPIToolsetinstance. It feeds everyOAuth2Sessioncall site inadk/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:scopesinauth_schemeso theOAuth2Sessionauthlib client can set thescope=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.scopesout of theauth_schemein the exchange or refresh code paths for providers that reject those requests containingscope=.doughayden commentedon May 29, 2026
Resolved on
mainby 6ce4b87 (#5874). Closing as fixed.fix(auth): strip redirect_uri from credential_key
fix(auth): strip redirect_uri from credential_key