Skip to content

Commit ceeb715

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 19d1519 commit ceeb715

6 files changed

Lines changed: 315 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1616
- 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.
1717
- Added a generic fallback to OIDC credential auto-discovery. When no dedicated environment is detected, the CLI reads an OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (useful for Jenkins or any custom CI/CD) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1818
- Added GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (configured via `id_tokens` in `.gitlab-ci.yml`) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
19-
- Added controls for OIDC detector selection. Set `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED=true` to skip a specific detector (only the literal `true` disables), or use `--oidc-detector-order` (env var `CLOUDSMITH_OIDC_DETECTOR_ORDER`) with a comma-separated list of detector ids to override which detectors are considered and the order they are tried in. When both are set, disable flags take precedence over the order list. Detector ids: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`.
19+
- 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.
20+
- Added controls for OIDC detector selection. Set `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED=true` to skip a specific detector (only the literal `true` disables), or use `--oidc-detector-order` (env var `CLOUDSMITH_OIDC_DETECTOR_ORDER`) with a comma-separated list of detector ids to override which detectors are considered and the order they are tried in. When both are set, disable flags take precedence over the order list. Detector ids: `aws`, `azure_devops`, `bitbucket`, `circleci`, `gcp`, `generic`, `github`, `gitlab`.
2021

2122
## [1.18.0] - 2026-06-09
2223

README.md

Lines changed: 12 additions & 2 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:
@@ -163,7 +173,7 @@ To install all optional dependencies:
163173
pip install cloudsmith-cli[all]
164174
```
165175

166-
**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors.
176+
**Note:** If you don't install the AWS or GCP extra, the corresponding OIDC detector will gracefully skip itself with no errors.
167177

168178
#### Bitbucket Pipelines OIDC Support
169179

@@ -218,7 +228,7 @@ By default the CLI tries each detector in a fixed priority order and uses the fi
218228
- **Disable a detector** — set `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED=true` to skip it entirely. Only the literal value `true` (case-insensitive) disables; anything else leaves the detector enabled. For example, `CLOUDSMITH_OIDC_AWS_DISABLED=true` skips the AWS detector so an explicitly-set `CLOUDSMITH_OIDC_TOKEN` is picked up by the generic detector instead.
219229
- **Reorder evaluation** — use `--oidc-detector-order` (or the `CLOUDSMITH_OIDC_DETECTOR_ORDER` environment variable) with a comma-separated list of detector ids to control both which detectors are considered and the order they are tried in (first match wins). Ids not listed are skipped; unrecognised ids are ignored. For example, `--oidc-detector-order=generic,aws` tries the generic detector first and considers only those two.
220230

221-
When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`.
231+
When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `circleci`, `gcp`, `generic`, `github`, `gitlab`.
222232

223233
## Configuration
224234

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .base import EnvironmentDetector
1111
from .bitbucket_pipelines import BitbucketPipelinesDetector
1212
from .circleci import CircleCIDetector
13+
from .gcp import GCPDetector
1314
from .generic import GenericDetector
1415
from .github_actions import GitHubActionsDetector
1516
from .gitlab_ci import GitLabCIDetector
@@ -28,6 +29,7 @@
2829
BitbucketPipelinesDetector,
2930
GitLabCIDetector,
3031
AWSDetector,
32+
GCPDetector,
3133
GenericDetector,
3234
]
3335

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""Google Cloud OIDC detector.
3+
4+
Discovers the ambient Google identity via google-auth's Application Default
5+
Credentials chain and mints an OIDC ID token for Cloudsmith.
6+
7+
Requires google-auth (optional dependency): pip install cloudsmith-cli[gcp]
8+
9+
References:
10+
https://docs.cloud.google.com/iam/docs/authenticate-with-auth-libraries#authenticate-standard
11+
https://googleapis.dev/python/google-auth/latest/index.html
12+
https://googleapis.dev/python/google-auth/latest/user-guide.html
13+
https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import logging
19+
20+
from .base import EnvironmentDetector
21+
22+
logger = logging.getLogger(__name__)
23+
24+
DEFAULT_AUDIENCE = "cloudsmith"
25+
26+
METADATA_IDENTITY_PATH = "instance/service-accounts/default/identity"
27+
28+
29+
class GCPDetector(EnvironmentDetector):
30+
"""Detects Google Cloud environments and obtains an OIDC ID token."""
31+
32+
name = "Google Cloud"
33+
id = "gcp"
34+
35+
def detect(self) -> bool:
36+
try:
37+
import google.auth
38+
from google.auth import exceptions
39+
except ImportError:
40+
logger.debug("google-auth not installed, skipping")
41+
return False
42+
43+
try:
44+
google.auth.default()
45+
return True
46+
except exceptions.DefaultCredentialsError:
47+
return self._on_gce()
48+
except exceptions.GoogleAuthError:
49+
logger.debug("Error during Google credential detection", exc_info=True)
50+
return False
51+
52+
def get_token(self) -> str:
53+
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
54+
55+
try:
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+
except ImportError as exc:
61+
raise ValueError(
62+
"Google Cloud detector requires google-auth; install it with "
63+
"pip install cloudsmith-cli[gcp]"
64+
) from exc
65+
66+
request = Request()
67+
try:
68+
credentials, _ = google.auth.default()
69+
except exceptions.DefaultCredentialsError:
70+
token = self._token_from_metadata(audience)
71+
else:
72+
if isinstance(credentials, compute_engine.Credentials):
73+
token = self._token_from_metadata(audience)
74+
elif isinstance(credentials, user_creds.Credentials):
75+
token = self._token_from_user_credentials(credentials, request)
76+
else:
77+
token = self._token_from_id_token_credentials(audience, request)
78+
79+
if not token:
80+
raise ValueError(
81+
"Google Cloud detector resolved Google credentials but could "
82+
"not mint an OIDC ID token."
83+
)
84+
return token
85+
86+
def _on_gce(self) -> bool:
87+
try:
88+
from google.auth import exceptions
89+
from google.auth.compute_engine import _metadata
90+
from google.auth.transport.requests import Request
91+
except ImportError:
92+
return False
93+
94+
try:
95+
return bool(_metadata.is_on_gce(Request()))
96+
except exceptions.GoogleAuthError:
97+
return False
98+
99+
def _token_from_metadata(self, audience: str) -> str | None:
100+
try:
101+
from google.auth import exceptions
102+
from google.auth.compute_engine import _metadata
103+
from google.auth.transport.requests import Request
104+
except ImportError:
105+
return None
106+
107+
try:
108+
token = _metadata.get(
109+
Request(),
110+
METADATA_IDENTITY_PATH,
111+
params={"audience": audience, "format": "full"},
112+
)
113+
except exceptions.GoogleAuthError:
114+
logger.debug("Metadata ID token fetch failed", exc_info=True)
115+
return None
116+
if not isinstance(token, str):
117+
return None
118+
return token.strip() or None
119+
120+
def _token_from_user_credentials(self, credentials, request) -> str | None:
121+
try:
122+
from google.auth import exceptions
123+
except ImportError:
124+
return None
125+
126+
try:
127+
credentials.refresh(request)
128+
except exceptions.GoogleAuthError:
129+
logger.debug("ADC id_token refresh failed", exc_info=True)
130+
return None
131+
return getattr(credentials, "id_token", None) or None
132+
133+
def _token_from_id_token_credentials(self, audience: str, request) -> str | None:
134+
try:
135+
from google.auth import exceptions
136+
from google.oauth2 import id_token
137+
except ImportError:
138+
return None
139+
140+
try:
141+
id_credentials = id_token.fetch_id_token_credentials(audience, request)
142+
id_credentials.refresh(request)
143+
return id_credentials.token or None
144+
except exceptions.GoogleAuthError:
145+
logger.debug("ID token credentials fetch failed", exc_info=True)
146+
return None
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
)
148+
149+
assert _DETECTORS.index(GCPDetector) == _DETECTORS.index(AWSDetector) + 1

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,12 @@ def get_long_description():
7272
"aws": [
7373
"boto3[crt]>=1.26.0",
7474
],
75+
"gcp": [
76+
"google-auth>=2.0.0",
77+
],
7578
"all": [
7679
"boto3[crt]>=1.26.0",
80+
"google-auth>=2.0.0",
7781
],
7882
},
7983
entry_points={

0 commit comments

Comments
 (0)