From 2d0c2e0567dde9926fe30c369139b1d91dcdd003 Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Thu, 16 Apr 2026 03:11:49 +0000 Subject: [PATCH] fix(auth): omit scope from OAuth2 refresh requests and guard None scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 6749 §6, the scope parameter is OPTIONAL in refresh requests — when omitted, providers treat it as equal to the originally-granted scope. Some providers (e.g. Salesforce) actively reject refresh requests that include a scope parameter, causing silent token refresh failures. Changes: 1. oauth2_credential_refresher.py: Pass scope="" to client.refresh_token() to prevent authlib from injecting the session's scope into the refresh request body. Empty string is falsy, so authlib's prepare_token_request omits it entirely from the POST body. 2. oauth2_credential_util.py: Guard against None scopes in both authorizationCode and clientCredentials flows. When scopes are not specified (valid per OpenAPI spec), default to an empty list instead of crashing with AttributeError: 'NoneType' has no attribute 'keys'. Fixes #5328 --- src/google/adk/auth/oauth2_credential_util.py | 12 +++- .../refresher/oauth2_credential_refresher.py | 5 ++ .../test_oauth2_credential_refresher.py | 62 +++++++++++++++++++ .../auth/test_oauth2_credential_util.py | 53 ++++++++++++++++ 4 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/google/adk/auth/oauth2_credential_util.py b/src/google/adk/auth/oauth2_credential_util.py index df2f26c002..e0fbc50b66 100644 --- a/src/google/adk/auth/oauth2_credential_util.py +++ b/src/google/adk/auth/oauth2_credential_util.py @@ -57,13 +57,21 @@ def create_oauth2_session( and auth_scheme.flows.authorizationCode.tokenUrl ): token_endpoint = auth_scheme.flows.authorizationCode.tokenUrl - scopes = list(auth_scheme.flows.authorizationCode.scopes.keys()) + scopes = ( + list(auth_scheme.flows.authorizationCode.scopes.keys()) + if auth_scheme.flows.authorizationCode.scopes + else [] + ) elif ( auth_scheme.flows.clientCredentials and auth_scheme.flows.clientCredentials.tokenUrl ): token_endpoint = auth_scheme.flows.clientCredentials.tokenUrl - scopes = list(auth_scheme.flows.clientCredentials.scopes.keys()) + scopes = ( + list(auth_scheme.flows.clientCredentials.scopes.keys()) + if auth_scheme.flows.clientCredentials.scopes + else [] + ) else: logger.warning( "OAuth2 scheme missing required flow configuration. Expected either" diff --git a/src/google/adk/auth/refresher/oauth2_credential_refresher.py b/src/google/adk/auth/refresher/oauth2_credential_refresher.py index 1c600db24c..eaba2fa19c 100644 --- a/src/google/adk/auth/refresher/oauth2_credential_refresher.py +++ b/src/google/adk/auth/refresher/oauth2_credential_refresher.py @@ -111,9 +111,14 @@ async def refresh( return auth_credential try: + # Explicitly omit scope from refresh requests per RFC 6749 §6. + # When scope is omitted, providers treat it as equal to the + # originally-granted scope. Some providers (e.g. Salesforce) + # actively reject refresh requests that include scope. tokens = client.refresh_token( url=token_endpoint, refresh_token=auth_credential.oauth2.refresh_token, + scope="", ) update_credential_with_tokens(auth_credential, tokens) logger.debug("Successfully refreshed OAuth2 tokens") diff --git a/tests/unittests/auth/refresher/test_oauth2_credential_refresher.py b/tests/unittests/auth/refresher/test_oauth2_credential_refresher.py index aa548dc4f4..53dd355352 100644 --- a/tests/unittests/auth/refresher/test_oauth2_credential_refresher.py +++ b/tests/unittests/auth/refresher/test_oauth2_credential_refresher.py @@ -177,3 +177,65 @@ async def test_needs_refresh_no_oauth2_credential(self): needs_refresh = await refresher.is_refresh_needed(credential, None) assert not needs_refresh + + @patch("google.adk.auth.oauth2_credential_util.OAuth2Session") + @patch("google.adk.auth.oauth2_credential_util.OAuth2Token") + @pytest.mark.asyncio + async def test_refresh_omits_scope_from_request( + self, mock_oauth2_token, mock_oauth2_session + ): + """Test that refresh_token is called with scope='' to omit scope. + + Per RFC 6749 §6, scope is OPTIONAL in refresh requests. When omitted, + providers treat it as equal to the originally-granted scope. Some + providers (e.g. Salesforce) actively reject refresh requests that + include a scope parameter. + + Regression test for https://github.com/google/adk-python/issues/5328 + """ + # Setup mock token + mock_token_instance = Mock() + mock_token_instance.is_expired.return_value = True + mock_oauth2_token.return_value = mock_token_instance + + # Setup mock session + mock_client = Mock() + mock_oauth2_session.return_value = mock_client + mock_tokens = OAuth2Token({ + "access_token": "refreshed", + "refresh_token": "refreshed_rt", + "expires_at": int(time.time()) + 3600, + "expires_in": 3600, + }) + mock_client.refresh_token.return_value = mock_tokens + + scheme = OpenIdConnectWithConfig( + type_="openIdConnect", + openId_connect_url=( + "https://example.com/.well-known/openid_configuration" + ), + authorization_endpoint="https://example.com/auth", + token_endpoint="https://example.com/token", + scopes=["openid", "profile"], + ) + credential = AuthCredential( + auth_type=AuthCredentialTypes.OPEN_ID_CONNECT, + oauth2=OAuth2Auth( + client_id="test_client_id", + client_secret="test_client_secret", + access_token="expired_token", + refresh_token="my_refresh_token", + expires_at=int(time.time()) - 3600, + ), + ) + + refresher = OAuth2CredentialRefresher() + await refresher.refresh(credential, scheme) + + # Verify scope="" is passed to suppress authlib from injecting + # the session's scope into the refresh request body. + mock_client.refresh_token.assert_called_once_with( + url="https://example.com/token", + refresh_token="my_refresh_token", + scope="", + ) diff --git a/tests/unittests/auth/test_oauth2_credential_util.py b/tests/unittests/auth/test_oauth2_credential_util.py index b9d4da6711..b3953337bb 100644 --- a/tests/unittests/auth/test_oauth2_credential_util.py +++ b/tests/unittests/auth/test_oauth2_credential_util.py @@ -19,6 +19,7 @@ from authlib.oauth2.rfc6749 import OAuth2Token from fastapi.openapi.models import OAuth2 from fastapi.openapi.models import OAuthFlowAuthorizationCode +from fastapi.openapi.models import OAuthFlowClientCredentials from fastapi.openapi.models import OAuthFlows from google.adk.auth.auth_credential import AuthCredential from google.adk.auth.auth_credential import AuthCredentialTypes @@ -246,3 +247,55 @@ def test_update_credential_with_tokens_none(self) -> None: # Should not raise any exceptions when oauth2 is None update_credential_with_tokens(credential, tokens) assert credential.oauth2 is None + + def test_create_oauth2_session_client_credentials_none_scopes(self): + """Test create_oauth2_session handles None scopes in clientCredentials flow. + + When OAuthFlowClientCredentials is created without specifying scopes, + the scopes attribute is None. Previously this caused: + AttributeError: 'NoneType' object has no attribute 'keys' + + Regression test for https://github.com/google/adk-python/issues/5328 + """ + scheme = OAuth2( + flows=OAuthFlows( + clientCredentials=OAuthFlowClientCredentials( + tokenUrl="https://example.com/token", + # scopes intentionally omitted (None) + ) + ) + ) + credential = create_oauth2_auth_credential( + auth_type=AuthCredentialTypes.OAUTH2 + ) + + client, token_endpoint = create_oauth2_session(scheme, credential) + + assert client is not None + assert token_endpoint == "https://example.com/token" + # Session scope should be empty since no scopes were provided + assert client.scope == "" + + def test_create_oauth2_session_auth_code_none_scopes(self): + """Test create_oauth2_session handles None scopes in authorizationCode flow. + + Regression test for https://github.com/google/adk-python/issues/5328 + """ + scheme = OAuth2( + flows=OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl="https://example.com/auth", + tokenUrl="https://example.com/token", + # scopes intentionally omitted (None) + ) + ) + ) + credential = create_oauth2_auth_credential( + auth_type=AuthCredentialTypes.OAUTH2 + ) + + client, token_endpoint = create_oauth2_session(scheme, credential) + + assert client is not None + assert token_endpoint == "https://example.com/token" + assert client.scope == ""