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
18 changes: 17 additions & 1 deletion src/google/adk/auth/auth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
23 changes: 17 additions & 6 deletions src/google/adk/workflow/utils/_workflow_hitl_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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


Expand Down
59 changes: 58 additions & 1 deletion tests/unittests/auth/test_auth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
)