From 0f252c66f7574d46766105446da00e0b2186977c Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 21:13:26 +0400 Subject: [PATCH 1/2] fix(auth): allow OIDC token exchange without client_secret Public clients such as Azure AD B2C reject a client_secret on the token request. Session creation and AuthHandler required one. Fixes #2256 --- src/google/adk/auth/auth_handler.py | 11 +++--- src/google/adk/auth/oauth2_credential_util.py | 15 ++++++-- .../openapi_spec_parser/tool_auth_handler.py | 5 --- tests/unittests/auth/test_auth_handler.py | 34 ++++++++++++++++--- .../auth/test_oauth2_credential_util.py | 34 ++++++++++++++++--- .../test_tool_auth_handler.py | 22 ++++++++++++ 6 files changed, 99 insertions(+), 22 deletions(-) diff --git a/src/google/adk/auth/auth_handler.py b/src/google/adk/auth/auth_handler.py index aa0c75491a1..bf5e5e5212a 100644 --- a/src/google/adk/auth/auth_handler.py +++ b/src/google/adk/auth/auth_handler.py @@ -290,14 +290,11 @@ def _generate_auth_request(self) -> AuthConfig: credential_key=self.auth_config.credential_key, ) - # Check for client_id and client_secret - if ( - not self.auth_config.raw_auth_credential.oauth2.client_id - or not self.auth_config.raw_auth_credential.oauth2.client_secret - ): + # Public clients (Azure AD B2C, PKCE) have a client_id and no secret. + if not self.auth_config.raw_auth_credential.oauth2.client_id: raise ValueError( - f"Auth Scheme {self.auth_config.auth_scheme.type_} requires both" - " client_id and client_secret in auth_credential.oauth2." + f"Auth Scheme {self.auth_config.auth_scheme.type_} requires" + " client_id in auth_credential.oauth2." ) # Generate new auth URI diff --git a/src/google/adk/auth/oauth2_credential_util.py b/src/google/adk/auth/oauth2_credential_util.py index 597c74eee63..0fa500717b6 100644 --- a/src/google/adk/auth/oauth2_credential_util.py +++ b/src/google/adk/auth/oauth2_credential_util.py @@ -83,10 +83,21 @@ def create_oauth2_session( not auth_credential or not auth_credential.oauth2 or not auth_credential.oauth2.client_id - or not auth_credential.oauth2.client_secret ): return None, None + # Public clients have no client_secret. The model default is + # client_secret_basic, which would send an empty Basic header. RFC 6749 + # token endpoint auth method "none" is the public-client value. + token_endpoint_auth_method: str | None = ( + auth_credential.oauth2.token_endpoint_auth_method + ) + if ( + not auth_credential.oauth2.client_secret + and token_endpoint_auth_method == "client_secret_basic" + ): + token_endpoint_auth_method = "none" + # Scope is intentionally omitted: token exchange and refresh don't require # it per RFC 6749, and some providers reject it on these requests. session = OAuth2Session( @@ -94,7 +105,7 @@ def create_oauth2_session( 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_auth_method=token_endpoint_auth_method, code_challenge_method=auth_credential.oauth2.code_challenge_method, default_timeout=_TOKEN_REQUEST_TIMEOUT_SECONDS, ) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py index ce47b9dc46a..20a28ad37eb 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.py @@ -295,11 +295,6 @@ def _request_credential(self) -> None: "OAuth2 credentials client_id is missing." ) - if not self.auth_credential.oauth2.client_secret: - raise AuthCredentialMissingError( - "OAuth2 credentials client_secret is missing." - ) - self.tool_context.request_credential(self._build_auth_config()) return None diff --git a/tests/unittests/auth/test_auth_handler.py b/tests/unittests/auth/test_auth_handler.py index 6a62f66e4a4..918631d04eb 100644 --- a/tests/unittests/auth/test_auth_handler.py +++ b/tests/unittests/auth/test_auth_handler.py @@ -514,7 +514,7 @@ def test_auth_uri_in_raw_credential( ) def test_missing_client_credentials(self, oauth2_auth_scheme): - """Test when client_id or client_secret is missing.""" + """Test when client_id is missing.""" bad_credential = AuthCredential( auth_type=AuthCredentialTypes.OAUTH2, oauth2=OAuth2Auth(redirect_uri="https://example.com/callback"), @@ -530,11 +530,37 @@ def test_missing_client_credentials(self, oauth2_auth_scheme): ) handler = AuthHandler(config) - with pytest.raises( - ValueError, match="requires both client_id and client_secret" - ): + with pytest.raises(ValueError, match="requires client_id"): handler.generate_auth_request() + @patch("google.adk.auth.auth_handler.AuthHandler.generate_auth_uri") + def test_public_client_without_client_secret( + self, mock_generate_auth_uri, oauth2_auth_scheme + ): + """Public clients can start the auth request with client_id only.""" + public_credential = AuthCredential( + auth_type=AuthCredentialTypes.OAUTH2, + oauth2=OAuth2Auth( + client_id="public-client", + redirect_uri="https://example.com/callback", + ), + ) + mock_generate_auth_uri.return_value = public_credential.model_copy( + deep=True + ) + config = AuthConfig( + auth_scheme=oauth2_auth_scheme, + raw_auth_credential=public_credential, + exchanged_auth_credential=public_credential.model_copy(deep=True), + ) + handler = AuthHandler(config) + + result = handler.generate_auth_request() + + mock_generate_auth_uri.assert_called_once() + assert result.raw_auth_credential.oauth2.client_id == "public-client" + assert result.raw_auth_credential.oauth2.client_secret is None + @patch("google.adk.auth.auth_handler.AuthHandler.generate_auth_uri") def test_generate_new_auth_uri(self, mock_generate_auth_uri, auth_config): """Test generating a new auth URI.""" diff --git a/tests/unittests/auth/test_oauth2_credential_util.py b/tests/unittests/auth/test_oauth2_credential_util.py index dd5489dd617..3cc95e73d56 100644 --- a/tests/unittests/auth/test_oauth2_credential_util.py +++ b/tests/unittests/auth/test_oauth2_credential_util.py @@ -128,8 +128,8 @@ def test_create_oauth2_session_invalid_scheme(self): assert client is None assert token_endpoint is None - def test_create_oauth2_session_missing_credentials(self): - """Test create_oauth2_session with missing credentials.""" + def test_create_oauth2_session_missing_client_id(self): + """Test create_oauth2_session with missing client_id.""" scheme = OpenIdConnectWithConfig( type_="openIdConnect", openId_connect_url=( @@ -142,8 +142,7 @@ def test_create_oauth2_session_missing_credentials(self): credential = AuthCredential( auth_type=AuthCredentialTypes.OPEN_ID_CONNECT, oauth2=OAuth2Auth( - client_id="test_client_id", - # Missing client_secret + client_secret="test_client_secret", ), ) @@ -152,6 +151,33 @@ def test_create_oauth2_session_missing_credentials(self): assert client is None assert token_endpoint is None + def test_create_oauth2_session_public_client_without_secret(self): + """Public clients have a client_id and no client_secret.""" + 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"], + ) + credential = AuthCredential( + auth_type=AuthCredentialTypes.OPEN_ID_CONNECT, + oauth2=OAuth2Auth( + client_id="public-client", + redirect_uri="https://app/cb", + ), + ) + + client, token_endpoint = create_oauth2_session(scheme, credential) + + assert client is not None + assert token_endpoint == "https://example.com/token" + assert client.client_id == "public-client" + assert client.client_secret is None + assert client.token_endpoint_auth_method == "none" + def _google_openid_scheme(self) -> OpenIdConnectWithConfig: """OpenID Connect scheme that uses Google's OAuth2 token endpoint.""" return OpenIdConnectWithConfig( diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py index 0d52d08aae8..1122a5e53a9 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py @@ -118,6 +118,28 @@ def openid_connect_credential(): return credential +@pytest.mark.asyncio +async def test_openid_connect_public_client_without_secret( + openid_connect_scheme, +): + public_credential = AuthCredential( + auth_type=AuthCredentialTypes.OPEN_ID_CONNECT, + oauth2=OAuth2Auth( + client_id='public-client', + redirect_uri='https://app/cb', + ), + ) + tool_context = create_mock_tool_context() + handler = ToolAuthHandler( + tool_context, + openid_connect_scheme, + public_credential, + ) + result = await handler.prepare_auth_credentials() + assert result.state == 'pending' + assert result.auth_credential == public_credential + + @pytest.mark.asyncio async def test_openid_connect_no_auth_response( openid_connect_scheme, openid_connect_credential From 9a4aa1149765398d15c901d41c6b97d0aa3fc991 Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 22:00:42 +0400 Subject: [PATCH 2/2] fix(auth): exchange public-client OIDC codes without a secret _is_exchangeable still required client_secret, so parse_and_store and get_auth_response never called the exchanger after login. Fixes #2256 --- src/google/adk/auth/auth_handler.py | 7 +- tests/unittests/auth/test_auth_handler.py | 66 +++++++++++++++++++ .../test_tool_auth_handler.py | 40 +++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/google/adk/auth/auth_handler.py b/src/google/adk/auth/auth_handler.py index bf5e5e5212a..418abd56fb3 100644 --- a/src/google/adk/auth/auth_handler.py +++ b/src/google/adk/auth/auth_handler.py @@ -148,12 +148,7 @@ def _is_exchangeable(self, credential: AuthCredential | None) -> bool: ): return False oauth2 = credential.oauth2 if credential else None - return bool( - oauth2 - and not oauth2.access_token - and oauth2.client_id - and oauth2.client_secret - ) + return bool(oauth2 and not oauth2.access_token and oauth2.client_id) def _read_stored_credential( self, state: State diff --git a/tests/unittests/auth/test_auth_handler.py b/tests/unittests/auth/test_auth_handler.py index 918631d04eb..7df7467f990 100644 --- a/tests/unittests/auth/test_auth_handler.py +++ b/tests/unittests/auth/test_auth_handler.py @@ -769,6 +769,43 @@ def test_reattaches_configured_client_for_exchange( assert state[credential_key].oauth2.access_token == "mock_access_token" assert state[credential_key].oauth2.client_secret is None + @patch("google.adk.auth.oauth2_credential_util.OAuth2Session") + def test_get_auth_response_exchanges_public_client( + self, mock_oauth2_session, oauth2_auth_scheme + ): + """Public clients exchange an auth code with client_id only.""" + public = AuthCredential( + auth_type=AuthCredentialTypes.OAUTH2, + oauth2=OAuth2Auth( + client_id="public-client", + redirect_uri="https://example.com/callback", + ), + ) + stored = public.model_copy(deep=True) + stored.oauth2.auth_code = "public-auth-code" + stored.oauth2.auth_response_uri = ( + "https://example.com/callback?code=public-auth-code" + ) + config = AuthConfig( + auth_scheme=oauth2_auth_scheme, + raw_auth_credential=public, + exchanged_auth_credential=stored, + ) + mock_client = Mock() + mock_oauth2_session.return_value = mock_client + mock_client.fetch_token.return_value = OAuth2Token( + {"access_token": "public_access_token"} + ) + state = MockState() + state["temp:" + config.credential_key] = stored + + result = AuthHandler(config).get_auth_response(state) + + assert result.oauth2.access_token == "public_access_token" + assert mock_oauth2_session.call_args[0][0] == "public-client" + assert mock_oauth2_session.call_args[0][1] is None + assert mock_oauth2_session.return_value.fetch_token.called + class TestParseAndStoreAuthResponse: """Tests for the parse_and_store_auth_response method.""" @@ -812,6 +849,35 @@ async def test_oauth_scheme( assert state["temp:" + credential_key] == mock_exchange_token.return_value assert mock_exchange_token.called + @patch("google.adk.auth.auth_handler.AuthHandler.exchange_auth_token") + @pytest.mark.asyncio + async def test_oauth_scheme_public_client( + self, mock_exchange_token, oauth2_auth_scheme + ): + """Public clients still exchange an auth code (no client_secret).""" + public = AuthCredential( + auth_type=AuthCredentialTypes.OAUTH2, + oauth2=OAuth2Auth( + client_id="public-client", + redirect_uri="https://example.com/callback", + ), + ) + exchanged = public.model_copy(deep=True) + exchanged.oauth2.auth_code = "public-auth-code" + config = AuthConfig( + auth_scheme=oauth2_auth_scheme, + raw_auth_credential=public, + exchanged_auth_credential=exchanged, + ) + mock_exchange_token.return_value = AuthCredential( + auth_type=AuthCredentialTypes.OAUTH2, + oauth2=OAuth2Auth(access_token="exchanged_token"), + ) + + await AuthHandler(config).parse_and_store_auth_response(MockState()) + + assert mock_exchange_token.called + @pytest.mark.asyncio async def test_empty_credential_key_raises_error(self, oauth2_auth_scheme): """Test that ValueError is raised when credential_key is empty.""" diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py index 1122a5e53a9..e60ad2d57a8 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_tool_auth_handler.py @@ -140,6 +140,46 @@ async def test_openid_connect_public_client_without_secret( assert result.auth_credential == public_credential +@pytest.mark.asyncio +async def test_openid_connect_public_client_exchanges_auth_response( + openid_connect_scheme, monkeypatch +): + public_credential = AuthCredential( + auth_type=AuthCredentialTypes.OPEN_ID_CONNECT, + oauth2=OAuth2Auth( + client_id='public-client', + redirect_uri='https://app/cb', + ), + ) + stored = public_credential.model_copy(deep=True) + stored.oauth2.auth_code = 'public-auth-code' + stored.oauth2.auth_response_uri = 'https://app/cb?code=public-auth-code' + + tool_context = create_mock_tool_context() + handler = ToolAuthHandler( + tool_context, + openid_connect_scheme, + public_credential, + ) + auth_config = handler._build_auth_config() + tool_context.state['temp:' + auth_config.credential_key] = stored + + mock_client = MagicMock() + mock_client.fetch_token.return_value = { + 'access_token': 'public_access_token', + 'token_type': 'bearer', + } + monkeypatch.setattr( + 'google.adk.auth.oauth2_credential_util.OAuth2Session', + lambda *args, **kwargs: mock_client, + ) + + result = await handler.prepare_auth_credentials() + assert result.state == 'done' + assert result.auth_credential.auth_type == AuthCredentialTypes.HTTP + assert result.auth_credential.http.credentials.token == 'public_access_token' + + @pytest.mark.asyncio async def test_openid_connect_no_auth_response( openid_connect_scheme, openid_connect_credential