Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/google/adk/auth/oauth2_credential_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions src/google/adk/auth/refresher/oauth2_credential_refresher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
62 changes: 62 additions & 0 deletions tests/unittests/auth/refresher/test_oauth2_credential_refresher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="",
)
53 changes: 53 additions & 0 deletions tests/unittests/auth/test_oauth2_credential_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 == ""