🔴 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
OpenAPIToolset with OAuth2 authorizationCode flow and one or more scopes. Use a provider that rejects scope in 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() calls OAuth2CredentialRefresher.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.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.
🔴 Required Information
Describe the Bug:
OAuth2CredentialRefresher.refresh()transitively includes thescopeparameter in refresh token requests becausecreate_oauth2_session()passes scopes to authlib'sOAuth2Sessionconstructor. Salesforce rejects refresh requests that include ascopeparameter, 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,
scopeis OPTIONAL in refresh requests — when omitted, providers treat it as equal to the originally-granted scope. Since sendingscopeon 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:
OpenAPIToolsetwith OAuth2authorizationCodeflow and one or more scopes. Use a provider that rejectsscopein refresh requests (e.g. Salesforce).expires_at=<past-timestamp>in session state).ToolAuthHandler._get_existing_credential()callsOAuth2CredentialRefresher.refresh().invalid_request: scope parameter not supported(Salesforce).Expected Behavior:
The refresh should succeed — ADK should not send
scopein 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 insideOAuth2CredentialRefresher.refresh(), but the original stale credential is silently returned. The tool then executes with the expired access_token and receives a 401.Environment Details:
google-adk 1.28.0Model Information:
🟡 Optional Information
Logs:
Additional Context:
Root cause —
create_oauth2_sessioningoogle/adk/auth/oauth2_credential_util.py:authlib's
OAuth2Session.refresh_token()auto-includesself.scopein the refresh request body whenscopeis set on the session:Proposed fix — don't set
scopeon theOAuth2Sessionconstructor. Scopes are only used for authorization redirect URLs, which are built separately viaclient.create_authorization_url():Secondary issue — workaround hazard:
oauth2_credential_refresher.pydoesfrom 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 toimport oauth2_credential_util+oauth2_credential_util.create_oauth2_session(...)insiderefresh()to make the reference late-bound and patchable.Impact: Any ADK user integrating with Salesforce via
OpenAPIToolsetwill 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 thescopeparameter may be similarly affected.Companion issues (filed together):
ToolAuthHandler._get_existing_credentialrefreshes OAuth2 credentials in memory but doesn't persist them #5329 — Refreshed OAuth2 credentials are not persisted to the credential storeMinimal Reproduction Code:
Complete runnable reproduction — mirrors the
contributing/samples/oauth2_client_credentialslayout (agent.py+main.py+oauth2_test_server.py+README.md), adapted to use theauthorization_codeflow. The test server is run withSTRICT_SCOPE_REJECTION=1to mimic Salesforce's refresh behavior. A--apply-fixCLI 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-fixthe tool call's refresh fails (server rejectsscopein the refresh body); ADK falls through to_request_credentialand the agent asks for re-auth. With--apply-fixthe refresh succeeds and the tool returns weather data.Workaround applied in our project:
How often has this issue occurred?: Always (100%) — reproduces on every access_token expiry for affected providers.