Skip to content

Commit 4586050

Browse files
Merge remote-tracking branch 'origin/iduffy/gcp-oidc' into iduffy/oidc-detector-controls
* origin/iduffy/gcp-oidc: refactor: apply code review fixes to GCP OIDC detector fix: mint OIDC token via IAM Credentials on Cloud Build feat: add Google Cloud OIDC detector # Conflicts: # CHANGELOG.md # cloudsmith_cli/core/credentials/oidc/detectors/__init__.py
2 parents 6ad7afa + d8816e1 commit 4586050

10 files changed

Lines changed: 399 additions & 8 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
pip install -r requirements.txt
4040
4141
- name: Install package
42-
run: pip install -e .
42+
run: pip install -e ".[gcp]"
4343

4444
- name: Run pytest
4545
env:

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1515
- 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.
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 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`, with legacy fallbacks to `CI_JOB_JWT_V2` / `CI_JOB_JWT`) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
18+
- 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.
1819
- 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.
1920

2021
## [1.18.0] - 2026-06-09

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,24 @@ 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+
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+
158176
#### All Optional Features
159177

160178
To install all optional dependencies:
@@ -163,7 +181,7 @@ To install all optional dependencies:
163181
pip install cloudsmith-cli[all]
164182
```
165183

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

168186
#### Bitbucket Pipelines OIDC Support
169187

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
@@ -26,6 +27,7 @@
2627
GitHubActionsDetector,
2728
GitLabCIDetector,
2829
AWSDetector,
30+
GCPDetector,
2931
GenericDetector,
3032
]
3133

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,10 @@
1313

1414
import logging
1515

16-
from .base import EnvironmentDetector
16+
from .base import DEFAULT_AUDIENCE, EnvironmentDetector
1717

1818
logger = logging.getLogger(__name__)
1919

20-
DEFAULT_AUDIENCE = "cloudsmith"
21-
2220

2321
class AWSDetector(EnvironmentDetector):
2422
"""Detects AWS environments and obtains a JWT via STS GetWebIdentityToken."""

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
if TYPE_CHECKING:
88
from ... import CredentialContext
99

10+
DEFAULT_AUDIENCE = "cloudsmith"
11+
1012

1113
class EnvironmentDetector:
1214
"""Base class for OIDC environment detectors."""
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 DEFAULT_AUDIENCE, EnvironmentDetector
21+
22+
logger = logging.getLogger(__name__)
23+
24+
METADATA_IDENTITY_PATH = "instance/service-accounts/default/identity"
25+
26+
27+
class GCPDetector(EnvironmentDetector):
28+
"""Detects Google Cloud environments and obtains an OIDC ID token."""
29+
30+
name = "Google Cloud"
31+
32+
def __init__(self, context):
33+
super().__init__(context)
34+
self._credentials = None
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+
self._credentials, _ = 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 # pylint: disable=import-error
57+
from google.auth import ( # pylint: disable=import-error
58+
compute_engine,
59+
exceptions,
60+
)
61+
from google.auth.transport.requests import ( # pylint: disable=import-error
62+
Request,
63+
)
64+
from google.oauth2 import ( # pylint: disable=import-error
65+
credentials as user_creds,
66+
)
67+
68+
request = Request()
69+
credentials = self._credentials
70+
if credentials is None:
71+
try:
72+
credentials, _ = google.auth.default()
73+
except exceptions.DefaultCredentialsError:
74+
credentials = None
75+
76+
if credentials is None:
77+
token = self._token_from_metadata(audience, request)
78+
elif isinstance(credentials, compute_engine.Credentials):
79+
token = self._token_from_metadata(audience, request)
80+
if not token:
81+
token = self._token_from_compute_id_token_credentials(audience, request)
82+
elif isinstance(credentials, user_creds.Credentials):
83+
if self.context.oidc_audience:
84+
logger.debug(
85+
"GCPDetector: user credentials cannot mint a token for a "
86+
"custom audience; the configured oidc_audience is ignored"
87+
)
88+
token = self._token_from_user_credentials(credentials, request)
89+
else:
90+
token = self._token_from_id_token_credentials(audience, request)
91+
92+
if not token:
93+
raise ValueError(
94+
"Google Cloud detector resolved Google credentials but could "
95+
"not mint an OIDC ID token. Set CLOUDSMITH_OIDC_TOKEN to "
96+
"provide a token directly."
97+
)
98+
return token
99+
100+
def _on_gce(self) -> bool:
101+
from google.auth import exceptions # pylint: disable=import-error
102+
from google.auth.compute_engine import _metadata # pylint: disable=import-error
103+
from google.auth.transport.requests import ( # pylint: disable=import-error
104+
Request,
105+
)
106+
107+
try:
108+
return bool(_metadata.is_on_gce(Request()))
109+
except exceptions.GoogleAuthError:
110+
return False
111+
112+
def _token_from_metadata(self, audience: str, request) -> str | None:
113+
from google.auth import exceptions # pylint: disable=import-error
114+
from google.auth.compute_engine import _metadata # pylint: disable=import-error
115+
116+
try:
117+
token = _metadata.get(
118+
request,
119+
METADATA_IDENTITY_PATH,
120+
params={"audience": audience, "format": "full"},
121+
)
122+
except exceptions.GoogleAuthError:
123+
logger.debug("GCPDetector: metadata id token fetch failed", exc_info=True)
124+
return None
125+
if not isinstance(token, str):
126+
return None
127+
return token.strip() or None
128+
129+
def _token_from_compute_id_token_credentials(
130+
self, audience: str, request
131+
) -> str | None:
132+
# Cloud Build's metadata server has no identity (ID-token) endpoint, so
133+
# _token_from_metadata returns nothing there. Fall back to the IAM
134+
# Credentials API (generateIdToken), which the workload's service
135+
# account can call for itself when granted roles/iam.serviceAccountTokenCreator.
136+
from google.auth import ( # pylint: disable=import-error
137+
compute_engine,
138+
exceptions,
139+
)
140+
141+
try:
142+
id_credentials = compute_engine.IDTokenCredentials(
143+
request,
144+
target_audience=audience,
145+
use_metadata_identity_endpoint=False,
146+
)
147+
id_credentials.refresh(request)
148+
except exceptions.GoogleAuthError:
149+
logger.debug(
150+
"GCPDetector: compute ID token credentials fetch failed",
151+
exc_info=True,
152+
)
153+
return None
154+
return id_credentials.token or None
155+
156+
def _token_from_user_credentials(self, credentials, request) -> str | None:
157+
from google.auth import exceptions # pylint: disable=import-error
158+
159+
try:
160+
credentials.refresh(request)
161+
except exceptions.GoogleAuthError:
162+
logger.debug("GCPDetector: ADC id_token refresh failed", exc_info=True)
163+
return None
164+
return getattr(credentials, "id_token", None) or None
165+
166+
def _token_from_id_token_credentials(self, audience: str, request) -> str | None:
167+
from google.auth import exceptions # pylint: disable=import-error
168+
from google.oauth2 import id_token # pylint: disable=import-error
169+
170+
try:
171+
id_credentials = id_token.fetch_id_token_credentials(audience, request)
172+
id_credentials.refresh(request)
173+
return id_credentials.token or None
174+
except exceptions.GoogleAuthError:
175+
logger.debug(
176+
"GCPDetector: ID token credentials fetch failed", exc_info=True
177+
)
178+
return None

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@
1616
from urllib.parse import quote
1717

1818
from ....rest import create_requests_session as create_session
19-
from .base import EnvironmentDetector
20-
21-
DEFAULT_AUDIENCE = "cloudsmith"
19+
from .base import DEFAULT_AUDIENCE, EnvironmentDetector
2220

2321

2422
class GitHubActionsDetector(EnvironmentDetector):

0 commit comments

Comments
 (0)