Skip to content

Commit 22639e1

Browse files
feat: add Google Cloud OIDC detector
Adds a Google Cloud OIDC environment detector to the credential auto-discovery chain, mirroring the existing AWS detector. When CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG are set, the CLI mints an OIDC ID token from the ambient Google identity via the google-auth SDK and exchanges it for a short-lived Cloudsmith token. Discovery uses google.auth.default() -- the standard ADC chain (GOOGLE_APPLICATION_CREDENTIALS service-account key, local gcloud user credentials, then the GCE/Cloud Run/GKE/App Engine metadata server). get_token() turns the resolved credentials into an ID token: - compute/metadata credentials are queried via the metadata client with format=full, which adds the email/identity claims so users have more to validate against (the SDK's metadata ID-token path omits them); - local gcloud user credentials use their refreshed id_token directly (fetch_id_token_credentials does not support user credentials); - service-account keys, impersonated service accounts, and external accounts / Workload Identity Federation are delegated to google-auth's own fetch_id_token_credentials dispatch. detect() activates only when google-auth is importable and either ADC resolves or the metadata server is reachable (via the IP-based is_on_gce probe, avoiding slow DNS on non-GCE hosts); it never raises into the chain. google-auth is an optional dependency under a new [gcp] extra. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fd7fa2f commit 22639e1

6 files changed

Lines changed: 305 additions & 1 deletion

File tree

CHANGELOG.md

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

1313
- Added Azure DevOps to OIDC credential auto-discovery. When running in an Azure DevOps pipeline, the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN` and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1414
- Added GitHub Actions to OIDC credential auto-discovery. When running in GitHub Actions (with `id-token: write` permission), the CLI fetches an OIDC token from the Actions runtime endpoint and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
15+
- 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.
1516

1617
## [1.18.0] - 2026-06-09
1718

@@ -28,7 +29,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
2829

2930
- `metadata list` filters (`--source-kind`, `--classification`) now send the enum name the v2 API expects instead of an integer, fixing an HTTP 400 on every filtered list. Valid source kinds: `unknown, system, upstream, custom, third_party`; classifications: `unknown, intrinsic, security, provenance, sbom, generic`.
3031

31-
3232
## [1.17.0] - 2026-05-18
3333

3434
### Added

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,16 @@ pip install cloudsmith-cli[aws]
155155

156156
This installs `boto3[crt]` for AWS credential chain support, STS token generation, and AWS SSO compatibility.
157157

158+
#### GCP OIDC Support
159+
160+
For Google Cloud environments (GCE, Cloud Run, GKE, Cloud Functions, App Engine, Cloud Build), install with the `gcp` extra to enable automatic credential discovery:
161+
162+
```
163+
pip install cloudsmith-cli[gcp]
164+
```
165+
166+
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.
167+
158168
#### All Optional Features
159169

160170
To install all optional dependencies:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .aws import AWSDetector
99
from .azure_devops import AzureDevOpsDetector
1010
from .base import EnvironmentDetector
11+
from .gcp import GCPDetector
1112
from .github_actions import GitHubActionsDetector
1213

1314
if TYPE_CHECKING:
@@ -19,6 +20,7 @@
1920
AzureDevOpsDetector,
2021
GitHubActionsDetector,
2122
AWSDetector,
23+
GCPDetector,
2224
]
2325

2426

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Google Cloud OIDC detector.
2+
3+
Discovers the ambient Google identity via google-auth's Application Default
4+
Credentials chain and mints an OIDC ID token for Cloudsmith.
5+
6+
Requires google-auth (optional dependency): pip install cloudsmith-cli[gcp]
7+
8+
References:
9+
https://docs.cloud.google.com/iam/docs/authenticate-with-auth-libraries#authenticate-standard
10+
https://googleapis.dev/python/google-auth/latest/index.html
11+
https://googleapis.dev/python/google-auth/latest/user-guide.html
12+
https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth
13+
"""
14+
15+
# google-auth is an optional dependency; its imports are unavailable at lint time.
16+
# pylint: disable=import-error
17+
18+
from __future__ import annotations
19+
20+
import logging
21+
22+
from .base import EnvironmentDetector
23+
24+
logger = logging.getLogger(__name__)
25+
26+
DEFAULT_AUDIENCE = "cloudsmith"
27+
28+
METADATA_IDENTITY_PATH = "instance/service-accounts/default/identity"
29+
30+
31+
class GCPDetector(EnvironmentDetector):
32+
"""Detects Google Cloud environments and obtains an OIDC ID token."""
33+
34+
name = "Google Cloud"
35+
36+
def detect(self) -> bool:
37+
try:
38+
import google.auth
39+
from google.auth import exceptions
40+
except ImportError:
41+
logger.debug("GCPDetector: google-auth not installed, skipping")
42+
return False
43+
44+
try:
45+
google.auth.default()
46+
return True
47+
except exceptions.DefaultCredentialsError:
48+
return self._on_gce()
49+
except exceptions.GoogleAuthError:
50+
logger.debug("GCPDetector: error during detection", exc_info=True)
51+
return False
52+
53+
def get_token(self) -> str:
54+
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
55+
56+
import google.auth
57+
from google.auth import compute_engine, exceptions
58+
from google.auth.transport.requests import Request
59+
from google.oauth2 import credentials as user_creds
60+
61+
request = Request()
62+
try:
63+
credentials, _ = google.auth.default()
64+
except exceptions.DefaultCredentialsError:
65+
token = self._token_from_metadata(audience)
66+
else:
67+
if isinstance(credentials, compute_engine.Credentials):
68+
token = self._token_from_metadata(audience)
69+
elif isinstance(credentials, user_creds.Credentials):
70+
token = self._token_from_user_credentials(credentials, request)
71+
else:
72+
token = self._token_from_id_token_credentials(audience, request)
73+
74+
if not token:
75+
raise ValueError(
76+
"Google Cloud detector resolved Google credentials but could "
77+
"not mint an OIDC ID token. Set CLOUDSMITH_OIDC_TOKEN to "
78+
"provide a token directly."
79+
)
80+
return token
81+
82+
def _on_gce(self) -> bool:
83+
from google.auth import exceptions
84+
from google.auth.compute_engine import _metadata
85+
from google.auth.transport.requests import Request
86+
87+
try:
88+
return bool(_metadata.is_on_gce(Request()))
89+
except exceptions.GoogleAuthError:
90+
return False
91+
92+
def _token_from_metadata(self, audience: str) -> str | None:
93+
from google.auth import exceptions
94+
from google.auth.compute_engine import _metadata
95+
from google.auth.transport.requests import Request
96+
97+
try:
98+
token = _metadata.get(
99+
Request(),
100+
METADATA_IDENTITY_PATH,
101+
params={"audience": audience, "format": "full"},
102+
)
103+
except exceptions.GoogleAuthError:
104+
logger.debug("GCPDetector: metadata id token fetch failed", exc_info=True)
105+
return None
106+
if not isinstance(token, str):
107+
return None
108+
return token.strip() or None
109+
110+
def _token_from_user_credentials(self, credentials, request) -> str | None:
111+
from google.auth import exceptions
112+
113+
try:
114+
credentials.refresh(request)
115+
except exceptions.GoogleAuthError:
116+
logger.debug("GCPDetector: ADC id_token refresh failed", exc_info=True)
117+
return None
118+
return getattr(credentials, "id_token", None) or None
119+
120+
def _token_from_id_token_credentials(self, audience: str, request) -> str | None:
121+
from google.auth import exceptions
122+
from google.oauth2 import id_token
123+
124+
try:
125+
id_credentials = id_token.fetch_id_token_credentials(audience, request)
126+
id_credentials.refresh(request)
127+
return id_credentials.token or None
128+
except exceptions.GoogleAuthError:
129+
logger.debug(
130+
"GCPDetector: ID token credentials fetch failed", exc_info=True
131+
)
132+
return None
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""Tests for the Google Cloud OIDC detector."""
2+
3+
import sys
4+
from unittest import mock
5+
6+
import pytest
7+
8+
from cloudsmith_cli.core.credentials.models import CredentialContext
9+
from cloudsmith_cli.core.credentials.oidc.detectors.gcp import GCPDetector
10+
11+
compute_engine = pytest.importorskip("google.auth.compute_engine")
12+
exceptions = pytest.importorskip("google.auth.exceptions")
13+
oauth2_creds = pytest.importorskip("google.oauth2.credentials")
14+
15+
METADATA_GET = "google.auth.compute_engine._metadata.get"
16+
METADATA_IS_ON_GCE = "google.auth.compute_engine._metadata.is_on_gce"
17+
FETCH_ID_TOKEN_CREDS = "google.oauth2.id_token.fetch_id_token_credentials"
18+
19+
20+
def make_detector(**ctx):
21+
return GCPDetector(context=CredentialContext(**ctx))
22+
23+
24+
class TestDetect:
25+
def test_not_detected_when_google_auth_missing(self):
26+
with mock.patch.dict(sys.modules, {"google.auth": None}):
27+
assert make_detector().detect() is False
28+
29+
def test_detected_when_adc_resolves(self):
30+
with mock.patch("google.auth.default", return_value=(mock.MagicMock(), "proj")):
31+
assert make_detector().detect() is True
32+
33+
def test_detected_via_metadata_when_adc_unavailable(self):
34+
with mock.patch(
35+
"google.auth.default",
36+
side_effect=exceptions.DefaultCredentialsError("no adc"),
37+
), mock.patch(METADATA_IS_ON_GCE, return_value=True):
38+
assert make_detector().detect() is True
39+
40+
def test_not_detected_when_nothing_available(self):
41+
with mock.patch(
42+
"google.auth.default",
43+
side_effect=exceptions.DefaultCredentialsError("no adc"),
44+
), mock.patch(METADATA_IS_ON_GCE, return_value=False):
45+
assert make_detector().detect() is False
46+
47+
def test_not_detected_on_google_auth_error(self):
48+
with mock.patch(
49+
"google.auth.default", side_effect=exceptions.GoogleAuthError("boom")
50+
):
51+
assert make_detector().detect() is False
52+
53+
54+
class TestGetTokenMetadata:
55+
def test_compute_credentials_use_metadata_with_format_full(self):
56+
compute_creds = mock.Mock(spec=compute_engine.Credentials)
57+
with mock.patch(
58+
"google.auth.default", return_value=(compute_creds, "proj")
59+
), mock.patch(METADATA_GET, return_value="meta-jwt\n") as get:
60+
token = make_detector().get_token()
61+
assert token == "meta-jwt"
62+
args, kwargs = get.call_args
63+
assert args[1] == "instance/service-accounts/default/identity"
64+
assert kwargs["params"] == {"audience": "cloudsmith", "format": "full"}
65+
66+
def test_uses_configured_audience(self):
67+
compute_creds = mock.Mock(spec=compute_engine.Credentials)
68+
with mock.patch(
69+
"google.auth.default", return_value=(compute_creds, "proj")
70+
), mock.patch(METADATA_GET, return_value="meta-jwt") as get:
71+
make_detector(oidc_audience="custom-aud").get_token()
72+
_, kwargs = get.call_args
73+
assert kwargs["params"]["audience"] == "custom-aud"
74+
75+
def test_falls_back_to_metadata_when_default_raises(self):
76+
with mock.patch(
77+
"google.auth.default",
78+
side_effect=exceptions.DefaultCredentialsError("no adc"),
79+
), mock.patch(METADATA_GET, return_value="meta-jwt"):
80+
assert make_detector().get_token() == "meta-jwt"
81+
82+
def test_raises_when_metadata_returns_blank(self):
83+
compute_creds = mock.Mock(spec=compute_engine.Credentials)
84+
with mock.patch(
85+
"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()
89+
90+
def test_raises_when_metadata_errors(self):
91+
compute_creds = mock.Mock(spec=compute_engine.Credentials)
92+
with mock.patch(
93+
"google.auth.default", return_value=(compute_creds, "proj")
94+
), mock.patch(METADATA_GET, side_effect=exceptions.TransportError("boom")):
95+
with pytest.raises(ValueError):
96+
make_detector().get_token()
97+
98+
99+
class TestGetTokenUserCredentials:
100+
def test_returns_refreshed_id_token(self):
101+
user_creds = mock.Mock(spec=oauth2_creds.Credentials)
102+
user_creds.id_token = "adc-jwt"
103+
with mock.patch("google.auth.default", return_value=(user_creds, "proj")):
104+
token = make_detector().get_token()
105+
assert token == "adc-jwt"
106+
user_creds.refresh.assert_called_once()
107+
108+
def test_raises_when_user_credentials_have_no_id_token(self):
109+
user_creds = mock.Mock(spec=oauth2_creds.Credentials)
110+
user_creds.id_token = None
111+
with mock.patch("google.auth.default", return_value=(user_creds, "proj")):
112+
with pytest.raises(ValueError):
113+
make_detector().get_token()
114+
115+
116+
class TestGetTokenIdTokenCredentials:
117+
def test_delegates_to_fetch_id_token_credentials(self):
118+
# A non-compute, non-user credential (e.g. service-account key or
119+
# Workload Identity Federation) is delegated to google-auth's dispatch.
120+
other_creds = mock.Mock()
121+
id_creds = mock.Mock(token="sa-jwt")
122+
with mock.patch(
123+
"google.auth.default", return_value=(other_creds, "proj")
124+
), mock.patch(FETCH_ID_TOKEN_CREDS, return_value=id_creds) as fetch:
125+
token = make_detector(oidc_audience="aud").get_token()
126+
assert token == "sa-jwt"
127+
args, _ = fetch.call_args
128+
assert args[0] == "aud"
129+
id_creds.refresh.assert_called_once()
130+
131+
def test_raises_when_dispatch_fails(self):
132+
other_creds = mock.Mock()
133+
with mock.patch(
134+
"google.auth.default", return_value=(other_creds, "proj")
135+
), mock.patch(
136+
FETCH_ID_TOKEN_CREDS, side_effect=exceptions.DefaultCredentialsError("x")
137+
):
138+
with pytest.raises(ValueError):
139+
make_detector().get_token()
140+
141+
142+
class TestRegistration:
143+
def test_registered_after_aws(self):
144+
from cloudsmith_cli.core.credentials.oidc.detectors import (
145+
_DETECTORS,
146+
AWSDetector,
147+
AzureDevOpsDetector,
148+
GitHubActionsDetector,
149+
)
150+
151+
assert _DETECTORS == [
152+
AzureDevOpsDetector,
153+
GitHubActionsDetector,
154+
AWSDetector,
155+
GCPDetector,
156+
]

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ def get_long_description():
6969
"aws": [
7070
"boto3[crt]>=1.26.0",
7171
],
72+
"gcp": [
73+
"google-auth>=2.0.0",
74+
],
7275
"all": [
7376
"boto3[crt]>=1.26.0",
77+
"google-auth>=2.0.0",
7478
],
7579
},
7680
entry_points={

0 commit comments

Comments
 (0)