Skip to content

Commit a3c8526

Browse files
committed
fix(auth): handle missing client-credentials scopes safely
1 parent d337ddf commit a3c8526

4 files changed

Lines changed: 87 additions & 15 deletions

File tree

src/google/adk/auth/auth_handler.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .auth_schemes import OpenIdConnectWithConfig
2424
from .auth_tool import AuthConfig
2525
from .exchanger.oauth2_credential_exchanger import OAuth2CredentialExchanger
26+
from .oauth2_credential_util import _normalize_oauth_scopes
2627

2728
if TYPE_CHECKING:
2829
from ..sessions.state import State
@@ -161,7 +162,7 @@ def generate_auth_uri(
161162

162163
if isinstance(auth_scheme, OpenIdConnectWithConfig):
163164
authorization_endpoint = auth_scheme.authorization_endpoint
164-
scopes = auth_scheme.scopes
165+
scopes = _normalize_oauth_scopes(auth_scheme.scopes)
165166
else:
166167
authorization_endpoint = (
167168
auth_scheme.flows.implicit
@@ -173,17 +174,20 @@ def generate_auth_uri(
173174
or auth_scheme.flows.password
174175
and auth_scheme.flows.password.tokenUrl
175176
)
176-
scopes = (
177-
auth_scheme.flows.implicit
178-
and auth_scheme.flows.implicit.scopes
179-
or auth_scheme.flows.authorizationCode
180-
and auth_scheme.flows.authorizationCode.scopes
181-
or auth_scheme.flows.clientCredentials
182-
and auth_scheme.flows.clientCredentials.scopes
183-
or auth_scheme.flows.password
184-
and auth_scheme.flows.password.scopes
185-
)
186-
scopes = list(scopes.keys())
177+
if auth_scheme.flows.implicit:
178+
scopes = _normalize_oauth_scopes(auth_scheme.flows.implicit.scopes)
179+
elif auth_scheme.flows.authorizationCode:
180+
scopes = _normalize_oauth_scopes(
181+
auth_scheme.flows.authorizationCode.scopes
182+
)
183+
elif auth_scheme.flows.clientCredentials:
184+
scopes = _normalize_oauth_scopes(
185+
auth_scheme.flows.clientCredentials.scopes
186+
)
187+
elif auth_scheme.flows.password:
188+
scopes = _normalize_oauth_scopes(auth_scheme.flows.password.scopes)
189+
else:
190+
scopes = []
187191

188192
client = OAuth2Session(
189193
auth_credential.oauth2.client_id,

src/google/adk/auth/oauth2_credential_util.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@
3030
logger = logging.getLogger("google_adk." + __name__)
3131

3232

33+
def _normalize_oauth_scopes(scopes: Optional[dict[str, str] | list[str]]) -> list[str]:
34+
"""Normalize OAuth scopes into the list shape expected by authlib."""
35+
if not scopes:
36+
return []
37+
if isinstance(scopes, dict):
38+
return list(scopes.keys())
39+
return list(scopes)
40+
41+
3342
@experimental
3443
def create_oauth2_session(
3544
auth_scheme: AuthScheme,
@@ -49,21 +58,25 @@ def create_oauth2_session(
4958
logger.warning("OpenIdConnect scheme missing token_endpoint")
5059
return None, None
5160
token_endpoint = auth_scheme.token_endpoint
52-
scopes = auth_scheme.scopes or []
61+
scopes = _normalize_oauth_scopes(auth_scheme.scopes)
5362
elif isinstance(auth_scheme, OAuth2):
5463
# Support both authorization code and client credentials flows
5564
if (
5665
auth_scheme.flows.authorizationCode
5766
and auth_scheme.flows.authorizationCode.tokenUrl
5867
):
5968
token_endpoint = auth_scheme.flows.authorizationCode.tokenUrl
60-
scopes = list(auth_scheme.flows.authorizationCode.scopes.keys())
69+
scopes = _normalize_oauth_scopes(
70+
auth_scheme.flows.authorizationCode.scopes
71+
)
6172
elif (
6273
auth_scheme.flows.clientCredentials
6374
and auth_scheme.flows.clientCredentials.tokenUrl
6475
):
6576
token_endpoint = auth_scheme.flows.clientCredentials.tokenUrl
66-
scopes = list(auth_scheme.flows.clientCredentials.scopes.keys())
77+
scopes = _normalize_oauth_scopes(
78+
auth_scheme.flows.clientCredentials.scopes
79+
)
6780
else:
6881
logger.warning(
6982
"OAuth2 scheme missing required flow configuration. Expected either"

tests/unittests/auth/test_auth_handler.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from fastapi.openapi.models import APIKeyIn
2323
from fastapi.openapi.models import OAuth2
2424
from fastapi.openapi.models import OAuthFlowAuthorizationCode
25+
from fastapi.openapi.models import OAuthFlowClientCredentials
2526
from fastapi.openapi.models import OAuthFlows
2627
from google.adk.auth.auth_credential import AuthCredential
2728
from google.adk.auth.auth_credential import AuthCredentialTypes
@@ -271,6 +272,35 @@ def test_generate_auth_uri_openid(
271272
assert "client_id=mock_client_id" in result.oauth2.auth_uri
272273
assert result.oauth2.state == "mock_state"
273274

275+
@patch("google.adk.auth.auth_handler.OAuth2Session", MockOAuth2Session)
276+
def test_generate_auth_uri_client_credentials_with_missing_scopes(
277+
self, oauth2_credentials
278+
):
279+
"""Test client credentials flow tolerates missing scopes."""
280+
auth_scheme = OAuth2(
281+
flows=OAuthFlows(
282+
clientCredentials=OAuthFlowClientCredentials(
283+
tokenUrl="https://example.com/oauth2/token"
284+
)
285+
)
286+
)
287+
auth_scheme.flows.clientCredentials.scopes = None
288+
289+
config = AuthConfig(
290+
auth_scheme=auth_scheme,
291+
raw_auth_credential=oauth2_credentials,
292+
exchanged_auth_credential=oauth2_credentials.model_copy(deep=True),
293+
)
294+
295+
handler = AuthHandler(config)
296+
result = handler.generate_auth_uri()
297+
298+
assert (
299+
result.oauth2.auth_uri
300+
== "https://example.com/oauth2/token?client_id=mock_client_id&scope="
301+
)
302+
assert result.oauth2.state == "mock_state"
303+
274304

275305
class TestGenerateAuthRequest:
276306
"""Tests for the generate_auth_request method."""

tests/unittests/auth/test_oauth2_credential_util.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from authlib.oauth2.rfc6749 import OAuth2Token
2020
from fastapi.openapi.models import OAuth2
2121
from fastapi.openapi.models import OAuthFlowAuthorizationCode
22+
from fastapi.openapi.models import OAuthFlowClientCredentials
2223
from fastapi.openapi.models import OAuthFlows
2324
from google.adk.auth.auth_credential import AuthCredential
2425
from google.adk.auth.auth_credential import AuthCredentialTypes
@@ -207,6 +208,30 @@ def test_create_oauth2_session_oauth2_scheme_with_token_endpoint_auth_method(
207208
assert token_endpoint == "https://example.com/token"
208209
assert client.token_endpoint_auth_method == "client_secret_jwt"
209210

211+
def test_create_oauth2_session_client_credentials_with_missing_scopes(self):
212+
"""Test client credentials flow tolerates missing scopes."""
213+
flows = OAuthFlows(
214+
clientCredentials=OAuthFlowClientCredentials(
215+
tokenUrl="https://example.com/token"
216+
)
217+
)
218+
flows.clientCredentials.scopes = None
219+
scheme = OAuth2(type_="oauth2", flows=flows)
220+
credential = AuthCredential(
221+
auth_type=AuthCredentialTypes.OAUTH2,
222+
oauth2=OAuth2Auth(
223+
client_id="test_client_id",
224+
client_secret="test_client_secret",
225+
redirect_uri="https://example.com/callback",
226+
),
227+
)
228+
229+
client, token_endpoint = create_oauth2_session(scheme, credential)
230+
231+
assert client is not None
232+
assert token_endpoint == "https://example.com/token"
233+
assert client.scope == ""
234+
210235
def test_update_credential_with_tokens(self):
211236
"""Test update_credential_with_tokens function."""
212237
credential = AuthCredential(

0 commit comments

Comments
 (0)