diff --git a/src/google/adk/auth/auth_handler.py b/src/google/adk/auth/auth_handler.py index aa0c75491a1..718608fff64 100644 --- a/src/google/adk/auth/auth_handler.py +++ b/src/google/adk/auth/auth_handler.py @@ -50,12 +50,28 @@ def _normalize_oauth_scopes( def _credential_without_client_secret( credential: AuthCredential | None, ) -> AuthCredential | None: - """Returns a copy of credential with the OAuth2 client secret removed.""" + """Returns a copy of credential with the agent's own secrets removed. + + The auth request is sent to the client and stored in the session, so no + secret that belongs to the agent may travel in it. The OAuth2 client secret + is one such secret; an API key, an HTTP Basic password, and a service account + private key are the agent's too. They are re-supplied from the tool config + when the credential is exchanged. + """ if credential is None: return None redacted = credential.model_copy(deep=True) if redacted.oauth2 is not None: redacted.oauth2.client_secret = None + if redacted.api_key is not None: + redacted.api_key = None + if redacted.http is not None and redacted.http.credentials is not None: + redacted.http.credentials.password = None + if ( + redacted.service_account is not None + and redacted.service_account.service_account_credential is not None + ): + redacted.service_account.service_account_credential.private_key = None return redacted diff --git a/src/google/adk/workflow/utils/_workflow_hitl_utils.py b/src/google/adk/workflow/utils/_workflow_hitl_utils.py index 255c4a7fb39..5b016be8fcc 100644 --- a/src/google/adk/workflow/utils/_workflow_hitl_utils.py +++ b/src/google/adk/workflow/utils/_workflow_hitl_utils.py @@ -164,12 +164,12 @@ def _build_auth_message(auth_config: AuthConfig) -> str: def _without_client_secret(auth_config: AuthConfig) -> AuthConfig: - """Returns a copy of the auth config with the OAuth client secret removed. + """Returns a copy of the auth config with the agent's secrets removed. - The auth request is handed to the caller, which needs the authorization uri - and the state to complete the flow but never the developer's client secret. - The secret stays on this side and is supplied again when the response comes - back. + The caller needs the authorization uri and the state to complete the flow but + never the agent's own secrets: the OAuth2 client secret, an API key, an HTTP + Basic password, or a service account private key. They are re-supplied from + the node config when the credential is used. Args: auth_config: The auth configuration for the node. @@ -179,8 +179,19 @@ def _without_client_secret(auth_config: AuthConfig) -> AuthConfig: without_secret.raw_auth_credential, without_secret.exchanged_auth_credential, ): - if credential and credential.oauth2: + if not credential: + continue + if credential.oauth2: credential.oauth2.client_secret = None + if credential.api_key is not None: + credential.api_key = None + if credential.http and credential.http.credentials: + credential.http.credentials.password = None + if ( + credential.service_account + and credential.service_account.service_account_credential + ): + credential.service_account.service_account_credential.private_key = None return without_secret diff --git a/tests/unittests/auth/test_auth_handler.py b/tests/unittests/auth/test_auth_handler.py index 6a62f66e4a4..a325ae2a43c 100644 --- a/tests/unittests/auth/test_auth_handler.py +++ b/tests/unittests/auth/test_auth_handler.py @@ -450,7 +450,13 @@ def test_non_oauth_scheme(self): handler = AuthHandler(config) result = handler.generate_auth_request() - assert result == config + # api key is stripped from the client-facing request; everything else stays. + assert result.raw_auth_credential.api_key is None + assert result.exchanged_auth_credential.api_key is None + expected = config.model_copy(deep=True) + expected.raw_auth_credential.api_key = None + expected.exchanged_auth_credential.api_key = None + assert result == expected def test_with_existing_auth_uri(self, auth_config_with_exchanged): """Test when auth_uri already exists in exchanged credential.""" @@ -926,3 +932,54 @@ async def test_successful_token_exchange( assert result.oauth2.access_token == "mock_access_token" assert result.oauth2.refresh_token == "mock_refresh_token" assert result.auth_type == AuthCredentialTypes.OAUTH2 + + +def test_generate_auth_request_redacts_api_key(): + """A non-OAuth API key must not survive into the client-facing auth request.""" + auth_config = AuthConfig( + auth_scheme=APIKey(**{"in": APIKeyIn.header}, name="X-API-Key"), + raw_auth_credential=AuthCredential( + auth_type=AuthCredentialTypes.API_KEY, + api_key="super-secret-api-key", + ), + ) + + prepared = AuthHandler(auth_config).generate_auth_request() + + assert prepared.raw_auth_credential.api_key is None + + +def test_generate_auth_request_redacts_service_account_private_key(): + """A service account private key must not survive into the auth request.""" + from google.adk.auth.auth_credential import ServiceAccount + from google.adk.auth.auth_credential import ServiceAccountCredential + + auth_config = AuthConfig( + auth_scheme=APIKey(**{"in": APIKeyIn.header}, name="X-SA"), + raw_auth_credential=AuthCredential( + auth_type=AuthCredentialTypes.SERVICE_ACCOUNT, + service_account=ServiceAccount( + service_account_credential=ServiceAccountCredential( + type="service_account", + project_id="p", + private_key_id="kid", + private_key="-----BEGIN PRIVATE KEY-----secret-----END PRIVATE KEY-----", + client_email="a@p.iam.gserviceaccount.com", + client_id="1", + auth_uri="https://accounts.google.com/o/oauth2/auth", + token_uri="https://oauth2.googleapis.com/token", + auth_provider_x509_cert_url="https://www.googleapis.com/oauth2/v1/certs", + client_x509_cert_url="https://www.googleapis.com/robot/v1/x", + universe_domain="googleapis.com", + ), + scopes=["https://www.googleapis.com/auth/cloud-platform"], + ), + ), + ) + + prepared = AuthHandler(auth_config).generate_auth_request() + + assert ( + prepared.raw_auth_credential.service_account.service_account_credential.private_key + is None + )