feat: Support ID token exchange in ServiceAccountCredentialExchanger

Adds use_id_token and audience fields to ServiceAccount so that
ServiceAccountCredentialExchanger can produce ID tokens instead of
access tokens. This is required for authenticating to Cloud Run, Cloud
Functions, and other Google Cloud services that verify caller identity.
Close #4458

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 874630210
This commit is contained in:
George Weale
2026-02-24 08:39:03 -08:00
committed by Copybara-Service
parent c615757ba1
commit 7be90db24b
4 changed files with 406 additions and 110 deletions
@@ -534,7 +534,9 @@ class TestMCPTool:
)
# Create service account credential
service_account = ServiceAccount(scopes=["test"])
service_account = ServiceAccount(
scopes=["test"], use_default_credential=True
)
credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=service_account,
@@ -25,8 +25,23 @@ from google.adk.auth.auth_schemes import AuthSchemeType
from google.adk.tools.openapi_tool.auth.credential_exchangers.base_credential_exchanger import AuthCredentialMissingError
from google.adk.tools.openapi_tool.auth.credential_exchangers.service_account_exchanger import ServiceAccountCredentialExchanger
import google.auth
from google.auth import exceptions as google_auth_exceptions
import pytest
_ACCESS_TOKEN_MONKEYPATCH_TARGET = (
"google.adk.tools.openapi_tool.auth.credential_exchangers."
"service_account_exchanger.service_account.Credentials."
"from_service_account_info"
)
_ID_TOKEN_MONKEYPATCH_TARGET = (
"google.adk.tools.openapi_tool.auth.credential_exchangers."
"service_account_exchanger.service_account.IDTokenCredentials."
"from_service_account_info"
)
_FETCH_ID_TOKEN_MONKEYPATCH_TARGET = "google.oauth2.id_token.fetch_id_token"
@pytest.fixture
def service_account_exchanger():
@@ -41,50 +56,45 @@ def auth_scheme():
return scheme
def test_exchange_credential_success(
service_account_exchanger, auth_scheme, monkeypatch
@pytest.fixture
def sa_credential():
"""A minimal valid ServiceAccountCredential for testing."""
return ServiceAccountCredential(
type_="service_account",
project_id="test_project_id",
private_key_id="test_private_key_id",
private_key="-----BEGIN PRIVATE KEY-----...",
client_email="test@test.iam.gserviceaccount.com",
client_id="test_client_id",
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/metadata/x509/test"
),
universe_domain="googleapis.com",
)
_DEFAULT_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
# --- Access token exchange tests ---
def test_exchange_access_token_with_explicit_credentials(
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
):
"""Test successful exchange of service account credentials."""
mock_credentials = MagicMock()
mock_credentials.token = "mock_access_token"
mock_from_sa_info = MagicMock(return_value=mock_credentials)
monkeypatch.setattr(_ACCESS_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
# Mock the from_service_account_info method
mock_from_service_account_info = MagicMock(return_value=mock_credentials)
target_path = (
"google.adk.tools.openapi_tool.auth.credential_exchangers."
"service_account_exchanger.service_account.Credentials."
"from_service_account_info"
)
monkeypatch.setattr(
target_path,
mock_from_service_account_info,
)
# Mock the refresh method
mock_credentials.refresh = MagicMock()
# Create a valid AuthCredential with service account info
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
service_account_credential=ServiceAccountCredential(
type_="service_account",
project_id="your_project_id",
private_key_id="your_private_key_id",
private_key="-----BEGIN PRIVATE KEY-----...",
client_email="...@....iam.gserviceaccount.com",
client_id="your_client_id",
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/metadata/x509/..."
),
universe_domain="googleapis.com",
),
scopes=["https://www.googleapis.com/auth/cloud-platform"],
service_account_credential=sa_credential,
scopes=_DEFAULT_SCOPES,
),
)
@@ -95,7 +105,7 @@ def test_exchange_credential_success(
assert result.auth_type == AuthCredentialTypes.HTTP
assert result.http.scheme == "bearer"
assert result.http.credentials.token == "mock_access_token"
mock_from_service_account_info.assert_called_once()
mock_from_sa_info.assert_called_once()
mock_credentials.refresh.assert_called_once()
@@ -107,7 +117,7 @@ def test_exchange_credential_success(
(None, None, None),
],
)
def test_exchange_credential_use_default_credential_success(
def test_exchange_access_token_with_adc_sets_quota_project(
service_account_exchanger,
auth_scheme,
monkeypatch,
@@ -115,7 +125,6 @@ def test_exchange_credential_use_default_credential_success(
adc_project_id,
expected_quota_project_id,
):
"""Test successful exchange of service account credentials using default credential."""
mock_credentials = MagicMock()
mock_credentials.token = "mock_access_token"
mock_credentials.quota_project_id = cred_quota_project_id
@@ -128,7 +137,7 @@ def test_exchange_credential_use_default_credential_success(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
use_default_credential=True,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
scopes=["https://www.googleapis.com/auth/bigquery"],
),
)
@@ -146,26 +155,49 @@ def test_exchange_credential_use_default_credential_success(
)
else:
assert not result.http.additional_headers
# Verify google.auth.default is called with the correct scopes parameter
mock_google_auth_default.assert_called_once_with(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
scopes=["https://www.googleapis.com/auth/bigquery"]
)
mock_credentials.refresh.assert_called_once()
def test_exchange_credential_missing_auth_credential(
def test_exchange_access_token_with_adc_defaults_to_cloud_platform_scope(
service_account_exchanger, auth_scheme, monkeypatch
):
mock_credentials = MagicMock()
mock_credentials.token = "mock_access_token"
mock_credentials.quota_project_id = None
mock_google_auth_default = MagicMock(return_value=(mock_credentials, None))
monkeypatch.setattr(google.auth, "default", mock_google_auth_default)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
use_default_credential=True,
),
)
result = service_account_exchanger.exchange_credential(
auth_scheme, auth_credential
)
assert result.auth_type == AuthCredentialTypes.HTTP
assert result.http.scheme == "bearer"
assert result.http.credentials.token == "mock_access_token"
mock_google_auth_default.assert_called_once_with(scopes=_DEFAULT_SCOPES)
def test_exchange_raises_when_auth_credential_is_none(
service_account_exchanger, auth_scheme
):
"""Test missing auth credential during exchange."""
with pytest.raises(AuthCredentialMissingError) as exc_info:
service_account_exchanger.exchange_credential(auth_scheme, None)
assert "Service account credentials are missing" in str(exc_info.value)
def test_exchange_credential_missing_service_account_info(
def test_exchange_raises_when_service_account_is_none(
service_account_exchanger, auth_scheme
):
"""Test missing service account info during exchange."""
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
)
@@ -174,47 +206,188 @@ def test_exchange_credential_missing_service_account_info(
assert "Service account credentials are missing" in str(exc_info.value)
def test_exchange_credential_exchange_failure(
service_account_exchanger, auth_scheme, monkeypatch
def test_exchange_wraps_google_auth_error_as_missing_error(
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
):
"""Test failure during service account token exchange."""
mock_from_service_account_info = MagicMock(
side_effect=Exception("Failed to load credentials")
)
target_path = (
"google.adk.tools.openapi_tool.auth.credential_exchangers."
"service_account_exchanger.service_account.Credentials."
"from_service_account_info"
)
monkeypatch.setattr(
target_path,
mock_from_service_account_info,
mock_from_sa_info = MagicMock(
side_effect=ValueError("Failed to load credentials")
)
monkeypatch.setattr(_ACCESS_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
service_account_credential=ServiceAccountCredential(
type_="service_account",
project_id="your_project_id",
private_key_id="your_private_key_id",
private_key="-----BEGIN PRIVATE KEY-----...",
client_email="...@....iam.gserviceaccount.com",
client_id="your_client_id",
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/metadata/x509/..."
),
universe_domain="googleapis.com",
),
scopes=["https://www.googleapis.com/auth/cloud-platform"],
service_account_credential=sa_credential,
scopes=_DEFAULT_SCOPES,
),
)
with pytest.raises(AuthCredentialMissingError) as exc_info:
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
assert "Failed to exchange service account token" in str(exc_info.value)
mock_from_service_account_info.assert_called_once()
mock_from_sa_info.assert_called_once()
def test_exchange_raises_when_explicit_credentials_have_no_scopes(
service_account_exchanger, auth_scheme, sa_credential
):
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
service_account_credential=sa_credential,
),
)
with pytest.raises(AuthCredentialMissingError) as exc_info:
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
assert "scopes are required" in str(exc_info.value)
# --- ID token exchange tests ---
def test_exchange_id_token_with_explicit_credentials(
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
):
mock_id_credentials = MagicMock()
mock_id_credentials.token = "mock_id_token"
mock_from_sa_info = MagicMock(return_value=mock_id_credentials)
monkeypatch.setattr(_ID_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
service_account_credential=sa_credential,
scopes=_DEFAULT_SCOPES,
use_id_token=True,
audience="https://my-service.run.app",
),
)
result = service_account_exchanger.exchange_credential(
auth_scheme, auth_credential
)
assert result.auth_type == AuthCredentialTypes.HTTP
assert result.http.scheme == "bearer"
assert result.http.credentials.token == "mock_id_token"
assert result.http.additional_headers is None
mock_from_sa_info.assert_called_once()
assert (
mock_from_sa_info.call_args[1]["target_audience"]
== "https://my-service.run.app"
)
mock_id_credentials.refresh.assert_called_once()
def test_exchange_id_token_with_adc(
service_account_exchanger, auth_scheme, monkeypatch
):
mock_fetch_id_token = MagicMock(return_value="mock_adc_id_token")
monkeypatch.setattr(_FETCH_ID_TOKEN_MONKEYPATCH_TARGET, mock_fetch_id_token)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
use_default_credential=True,
scopes=_DEFAULT_SCOPES,
use_id_token=True,
audience="https://my-service.run.app",
),
)
result = service_account_exchanger.exchange_credential(
auth_scheme, auth_credential
)
assert result.auth_type == AuthCredentialTypes.HTTP
assert result.http.scheme == "bearer"
assert result.http.credentials.token == "mock_adc_id_token"
assert result.http.additional_headers is None
mock_fetch_id_token.assert_called_once()
assert mock_fetch_id_token.call_args[0][1] == "https://my-service.run.app"
def test_id_token_requires_audience():
with pytest.raises(
ValueError, match="audience is required when use_id_token is True"
):
ServiceAccount(
use_default_credential=True,
use_id_token=True,
)
def test_exchange_id_token_wraps_error_with_explicit_credentials(
service_account_exchanger, auth_scheme, sa_credential, monkeypatch
):
mock_from_sa_info = MagicMock(
side_effect=ValueError("Failed to create ID token credentials")
)
monkeypatch.setattr(_ID_TOKEN_MONKEYPATCH_TARGET, mock_from_sa_info)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
service_account_credential=sa_credential,
scopes=_DEFAULT_SCOPES,
use_id_token=True,
audience="https://my-service.run.app",
),
)
with pytest.raises(AuthCredentialMissingError) as exc_info:
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
assert "Failed to exchange service account for ID token" in str(
exc_info.value
)
def test_exchange_id_token_wraps_error_with_adc(
service_account_exchanger, auth_scheme, monkeypatch
):
mock_fetch_id_token = MagicMock(
side_effect=google_auth_exceptions.DefaultCredentialsError(
"Metadata service unavailable"
)
)
monkeypatch.setattr(_FETCH_ID_TOKEN_MONKEYPATCH_TARGET, mock_fetch_id_token)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.SERVICE_ACCOUNT,
service_account=ServiceAccount(
use_default_credential=True,
scopes=_DEFAULT_SCOPES,
use_id_token=True,
audience="https://my-service.run.app",
),
)
with pytest.raises(AuthCredentialMissingError) as exc_info:
service_account_exchanger.exchange_credential(auth_scheme, auth_credential)
assert "Failed to exchange service account for ID token" in str(
exc_info.value
)
# --- Model validator tests ---
def test_model_validator_rejects_missing_credential_without_adc():
with pytest.raises(
ValueError,
match="service_account_credential is required",
):
ServiceAccount(
use_default_credential=False,
scopes=_DEFAULT_SCOPES,
)
def test_model_validator_allows_adc_without_explicit_credential():
sa = ServiceAccount(
use_default_credential=True,
scopes=_DEFAULT_SCOPES,
)
assert sa.service_account_credential is None
assert sa.use_default_credential is True