Skip to content

Commit e8c47d0

Browse files
fix: mint OIDC token via IAM Credentials on Cloud Build
Cloud Build workers run as a service account but their metadata server exposes no identity (ID-token) endpoint, so _token_from_metadata 404s and the detector failed on Cloud Build despite it being listed as supported. Fall back to compute_engine.IDTokenCredentials (use_metadata_identity_endpoint=False), which mints the ID token via the IAM Credentials API. Requires the runtime SA to hold roles/iam.serviceAccountTokenCreator on itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 14908e8 commit e8c47d0

4 files changed

Lines changed: 67 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010

1111
### Added
1212

13-
- Added Google Cloud (GCP) support to OIDC credential auto-discovery. Install with the `gcp` extra (`pip install cloudsmith-cli[gcp]`) to authenticate using the ambient Google Cloud identity: the attached service account on GCE/Cloud Run/GKE/Cloud Functions/App Engine/Cloud Build, a `GOOGLE_APPLICATION_CREDENTIALS` service-account key, or a local `gcloud auth application-default login` session. The CLI discovers credentials via the `google-auth` SDK's Application Default Credentials chain, mints a short-lived OIDC ID token, and exchanges it for a Cloudsmith access token. Without the extra installed, the detector skips itself with no errors.
13+
- Added Google Cloud (GCP) support to OIDC credential auto-discovery. Install with the `gcp` extra (`pip install cloudsmith-cli[gcp]`) to authenticate using the ambient Google Cloud identity: the attached service account on GCE/Cloud Run/GKE/Cloud Functions/App Engine/Cloud Build, a `GOOGLE_APPLICATION_CREDENTIALS` service-account key, or a local `gcloud auth application-default login` session. The CLI discovers credentials via the `google-auth` SDK's Application Default Credentials chain, mints a short-lived OIDC ID token, and exchanges it for a Cloudsmith access token. On Cloud Build, where the metadata server exposes no ID-token endpoint, the token is minted via the IAM Credentials API (`generateIdToken`), which requires the build service account to hold `roles/iam.serviceAccountTokenCreator` on itself. Without the extra installed, the detector skips itself with no errors.
1414

1515
## [1.17.0] - 2026-05-18
1616

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ pip install cloudsmith-cli[gcp]
165165

166166
This installs `google-auth` for Application Default Credentials resolution and OIDC ID token generation. It works with the attached service account on Google Cloud workloads, a `GOOGLE_APPLICATION_CREDENTIALS` service-account key, or a local `gcloud auth application-default login` session. See Google's [Authenticate with auth libraries](https://docs.cloud.google.com/iam/docs/authenticate-with-auth-libraries#authenticate-standard) guide for how credentials are resolved.
167167

168+
On **Cloud Build** the metadata server does not expose an ID-token endpoint, so the CLI mints the token via the IAM Credentials API instead. This requires the build's runtime service account to be able to create tokens for itself — grant it `roles/iam.serviceAccountTokenCreator` on itself:
169+
170+
```
171+
gcloud iam service-accounts add-iam-policy-binding SERVICE_ACCOUNT_EMAIL \
172+
--member="serviceAccount:SERVICE_ACCOUNT_EMAIL" \
173+
--role="roles/iam.serviceAccountTokenCreator"
174+
```
175+
168176
#### All Optional Features
169177

170178
To install all optional dependencies:

cloudsmith_cli/core/credentials/oidc/detectors/gcp.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ def get_token(self) -> str:
6666
else:
6767
if isinstance(credentials, compute_engine.Credentials):
6868
token = self._token_from_metadata(audience)
69+
if not token:
70+
token = self._token_from_compute_id_token_credentials(
71+
audience, request
72+
)
6973
elif isinstance(credentials, user_creds.Credentials):
7074
token = self._token_from_user_credentials(credentials, request)
7175
else:
@@ -107,6 +111,30 @@ def _token_from_metadata(self, audience: str) -> str | None:
107111
return None
108112
return token.strip() or None
109113

114+
def _token_from_compute_id_token_credentials(
115+
self, audience: str, request
116+
) -> str | None:
117+
# Cloud Build's metadata server has no identity (ID-token) endpoint, so
118+
# _token_from_metadata returns nothing there. Fall back to the IAM
119+
# Credentials API (generateIdToken), which the workload's service
120+
# account can call for itself when granted roles/iam.serviceAccountTokenCreator.
121+
from google.auth import compute_engine, exceptions
122+
123+
try:
124+
id_credentials = compute_engine.IDTokenCredentials(
125+
request,
126+
target_audience=audience,
127+
use_metadata_identity_endpoint=False,
128+
)
129+
id_credentials.refresh(request)
130+
except exceptions.GoogleAuthError:
131+
logger.debug(
132+
"GCPDetector: compute ID token credentials fetch failed",
133+
exc_info=True,
134+
)
135+
return None
136+
return id_credentials.token or None
137+
110138
def _token_from_user_credentials(self, credentials, request) -> str | None:
111139
from google.auth import exceptions
112140

cloudsmith_cli/core/tests/test_gcp_detector.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
METADATA_GET = "google.auth.compute_engine._metadata.get"
1616
METADATA_IS_ON_GCE = "google.auth.compute_engine._metadata.is_on_gce"
1717
FETCH_ID_TOKEN_CREDS = "google.oauth2.id_token.fetch_id_token_credentials"
18+
COMPUTE_ID_TOKEN_CREDS = "google.auth.compute_engine.IDTokenCredentials"
1819

1920

2021
def make_detector(**ctx):
@@ -79,19 +80,42 @@ def test_falls_back_to_metadata_when_default_raises(self):
7980
), mock.patch(METADATA_GET, return_value="meta-jwt"):
8081
assert make_detector().get_token() == "meta-jwt"
8182

82-
def test_raises_when_metadata_returns_blank(self):
83+
def test_falls_back_to_id_token_credentials_when_metadata_blank(self):
84+
# Cloud Build exposes no metadata identity endpoint (404 -> blank), so
85+
# the compute path must fall back to IAM Credentials generateIdToken.
8386
compute_creds = mock.Mock(spec=compute_engine.Credentials)
87+
id_creds = mock.Mock(token="fallback-jwt")
8488
with mock.patch(
8589
"google.auth.default", return_value=(compute_creds, "proj")
86-
), mock.patch(METADATA_GET, return_value=" "):
87-
with pytest.raises(ValueError):
88-
make_detector().get_token()
90+
), mock.patch(METADATA_GET, return_value=" "), mock.patch(
91+
COMPUTE_ID_TOKEN_CREDS, return_value=id_creds
92+
) as id_token_credentials:
93+
token = make_detector(oidc_audience="aud").get_token()
94+
assert token == "fallback-jwt"
95+
_, kwargs = id_token_credentials.call_args
96+
assert kwargs["target_audience"] == "aud"
97+
assert kwargs["use_metadata_identity_endpoint"] is False
98+
id_creds.refresh.assert_called_once()
99+
100+
def test_falls_back_to_id_token_credentials_when_metadata_errors(self):
101+
compute_creds = mock.Mock(spec=compute_engine.Credentials)
102+
id_creds = mock.Mock(token="fallback-jwt")
103+
with mock.patch(
104+
"google.auth.default", return_value=(compute_creds, "proj")
105+
), mock.patch(
106+
METADATA_GET, side_effect=exceptions.TransportError("boom")
107+
), mock.patch(
108+
COMPUTE_ID_TOKEN_CREDS, return_value=id_creds
109+
):
110+
assert make_detector().get_token() == "fallback-jwt"
89111

90-
def test_raises_when_metadata_errors(self):
112+
def test_raises_when_metadata_and_fallback_both_fail(self):
91113
compute_creds = mock.Mock(spec=compute_engine.Credentials)
92114
with mock.patch(
93115
"google.auth.default", return_value=(compute_creds, "proj")
94-
), mock.patch(METADATA_GET, side_effect=exceptions.TransportError("boom")):
116+
), mock.patch(METADATA_GET, return_value=" "), mock.patch(
117+
COMPUTE_ID_TOKEN_CREDS, side_effect=exceptions.RefreshError("no perm")
118+
):
95119
with pytest.raises(ValueError):
96120
make_detector().get_token()
97121

0 commit comments

Comments
 (0)