Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Added

- Added Google Cloud (GCP) support to OIDC credential auto-discovery. Install with the `gcp` extra (`pip install cloudsmith-cli[gcp]`) to use an attached service account, a `GOOGLE_APPLICATION_CREDENTIALS` service-account key, or local service-account impersonation via ADC. The CLI requests an ID token with audience `cloudsmith` by default, using IAM `generateIdToken` if the metadata identity endpoint returns 404 (requires `iam.serviceAccounts.getOpenIdToken`). User ADC requires its OAuth client ID as the explicitly configured audience. Unsupported ADC types and installations without the extra are skipped. The detector supports the existing order/disable controls using the id `gcp`.

### Fixed

- MCP tools that use the v2 API, such as `analytics_logs_package_list`, no longer fail with a 404 error. Previously, the MCP server sent these requests without the `/v2` prefix.
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,29 @@ pip install cloudsmith-cli[aws]

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

#### GCP OIDC Support

For Google Cloud environments (GCE, Cloud Run, GKE, Cloud Functions, App Engine, Cloud Build), install with the `gcp` extra to enable automatic credential discovery:

```
pip install cloudsmith-cli[gcp]
```

This installs `google-auth` for Application Default Credentials (ADC) resolution and OIDC ID token generation. It works with an attached Google Cloud service account, a service-account key supplied through `GOOGLE_APPLICATION_CREDENTIALS`, or service-account impersonation configured locally:

```sh
gcloud auth application-default login --impersonate-service-account=SERVICE_ACCOUNT_EMAIL
cloudsmith whoami --workspace=YOUR_WORKSPACE --oidc-service-slug=YOUR_SERVICE --oidc-detector-order=gcp
```

Configure a Cloudsmith OIDC provider to trust `https://accounts.google.com`, the service account's numeric unique ID as the subject, and audience `cloudsmith` (override with `--oidc-audience` or `CLOUDSMITH_OIDC_AUDIENCE`). Local impersonation requires the Service Account Token Creator role on the target service account and the IAM Service Account Credentials API enabled.

On workloads with a metadata identity endpoint, the CLI requests an ID token directly. If that endpoint returns HTTP 404 (as on some Cloud Build environments), it uses IAM `generateIdToken` for the same attached service account. This fallback requires the IAM Service Account Credentials API and `iam.serviceAccounts.getOpenIdToken` on that account; the Service Account OpenID Connect Identity Token Creator role grants this permission. Permission and transport failures are reported, not treated as a missing endpoint.

A plain `gcloud auth application-default login` user session cannot mint an ID token for an arbitrary audience. To use it, set `--oidc-audience` to the session's OAuth client ID and configure Cloudsmith to trust that audience and user subject; otherwise use service-account impersonation. `gcloud auth login` alone does not configure ADC. Other ADC types, including direct external-account federation, are skipped rather than incorrectly treated as ID-token-capable credentials.

See Google's [Authenticate with auth libraries](https://docs.cloud.google.com/iam/docs/authenticate-with-auth-libraries#authenticate-standard) guide for credential discovery.

#### All Optional Features

To install all optional dependencies:
Expand All @@ -187,7 +210,7 @@ To install all optional dependencies:
pip install cloudsmith-cli[all]
```

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

#### Bitbucket Pipelines OIDC Support

Expand Down Expand Up @@ -246,7 +269,7 @@ By default the CLI tries each detector in a fixed priority order and uses the fi
- **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.
- **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 with a warning. For example, `--oidc-detector-order=generic,aws` tries the generic detector first and considers only those two.

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`, `buildkite`, `circleci`, `generic`, `github`, `gitlab`.
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`, `buildkite`, `circleci`, `gcp`, `generic`, `github`, `gitlab`.

Both controls can also be set in `config.ini`, under `[default]` or a profile section:

Expand Down
2 changes: 2 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .bitbucket_pipelines import BitbucketPipelinesDetector
from .buildkite import BuildkiteDetector
from .circleci import CircleCIDetector
from .gcp import GCPDetector
from .generic import GenericDetector
from .github_actions import GitHubActionsDetector
from .gitlab_ci import GitLabCIDetector
Expand All @@ -30,6 +31,7 @@
BitbucketPipelinesDetector,
GitLabCIDetector,
AWSDetector,
GCPDetector,
GenericDetector,
]

Expand Down
193 changes: 193 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/gcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Copyright 2026 Cloudsmith Ltd
"""Google Cloud OIDC detector.

Discovers the ambient Google identity via google-auth's Application Default
Credentials chain and mints an OIDC ID token for Cloudsmith.

Requires google-auth (optional dependency): pip install cloudsmith-cli[gcp]

References:
https://docs.cloud.google.com/iam/docs/authenticate-with-auth-libraries#authenticate-standard
https://googleapis.dev/python/google-auth/latest/index.html
https://googleapis.dev/python/google-auth/latest/user-guide.html
https://github.com/googleapis/google-cloud-python/tree/main/packages/google-auth
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from .base import EnvironmentDetector

if TYPE_CHECKING:
from google.auth.credentials import Credentials
from google.auth.transport.requests import Request

from ...models import CredentialContext

logger = logging.getLogger(__name__)

DEFAULT_AUDIENCE = "cloudsmith"

METADATA_IDENTITY_PATH = "instance/service-accounts/default/identity"


class GCPDetector(EnvironmentDetector):
"""Detects Google Cloud environments and obtains an OIDC ID token."""

name = "Google Cloud"
id = "gcp"

def __init__(self, context: CredentialContext):
super().__init__(context)
self._credentials: Credentials | None = None

def detect(self) -> bool:
try:
import google.auth
from google.auth import compute_engine, exceptions, impersonated_credentials
from google.oauth2 import credentials as user_creds
from google.oauth2 import service_account
except ImportError:
logger.debug("google-auth not installed, skipping")
return False

try:
credentials, _ = google.auth.default()
except exceptions.GoogleAuthError:
logger.debug("Error during Google credential detection", exc_info=True)
return False

if isinstance(credentials, user_creds.Credentials):
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
if audience != credentials.client_id:
logger.debug(
"Google user ADC audience does not match %s, skipping", audience
)
return False

if not isinstance(
credentials,
(
compute_engine.Credentials,
impersonated_credentials.Credentials,
service_account.Credentials,
user_creds.Credentials,
),
):
logger.debug(
"Google ADC type %s does not support ID token generation, skipping",
type(credentials).__name__,
)
return False
self._credentials = credentials
return True
Comment thread
Copilot marked this conversation as resolved.

def get_token(self) -> str:
audience = self.context.oidc_audience or DEFAULT_AUDIENCE

try:
import google.auth
from google.auth import compute_engine, impersonated_credentials
from google.auth.transport.requests import Request
from google.oauth2 import credentials as user_creds
from google.oauth2 import service_account
except ImportError as exc:
raise ValueError(
"Google Cloud detector requires google-auth; install it with "
"pip install cloudsmith-cli[gcp]"
) from exc

request = Request()
credentials = self._credentials
if credentials is None:
credentials, _ = google.auth.default()

if isinstance(credentials, compute_engine.Credentials):
token = self._token_from_metadata(credentials, audience, request)
elif isinstance(credentials, user_creds.Credentials):
token = self._token_from_user_credentials(credentials, audience, request)
else:
if isinstance(credentials, impersonated_credentials.Credentials):
id_credentials = impersonated_credentials.IDTokenCredentials(
credentials, target_audience=audience, include_email=True
)
elif isinstance(credentials, service_account.Credentials):
id_credentials = service_account.IDTokenCredentials(
credentials.signer,
credentials.service_account_email,
# google-auth has no public token URI accessor or conversion
# from an already-resolved service-account credential.
token_uri=credentials._token_uri,
target_audience=audience,
)
else:
raise TypeError(
"Google ADC credentials cannot mint an OIDC ID token. "
"Use an attached service account, a service-account key, or "
"gcloud auth application-default login "
"--impersonate-service-account=SERVICE_ACCOUNT_EMAIL."
)
id_credentials.refresh(request)
token = id_credentials.token

if not isinstance(token, str) or not token.strip():
raise ValueError(
"Google Cloud detector resolved Google credentials but could "
"not mint an OIDC ID token."
)
return token.strip()

def _token_from_metadata(
self, credentials: Credentials, audience: str, request: Request
) -> str | None:
from google.auth import exceptions, impersonated_credentials
from google.auth.compute_engine import _metadata

try:
return _metadata.get(
request,
METADATA_IDENTITY_PATH,
params={"audience": audience, "format": "full"},
)
except exceptions.TransportError as exc:
# Some Cloud Build metadata servers expose access tokens but no
# identity endpoint. Only a missing endpoint should use IAM instead.
response = exc.args[1] if len(exc.args) > 1 else None
if getattr(response, "status", None) != 404:
raise

logger.debug(
"Metadata identity endpoint unavailable; using IAM generateIdToken"
)
email = _metadata.get(request, "instance/service-accounts/default/email")
if not isinstance(email, str) or not email.strip():
raise ValueError("Google metadata returned no service-account email.")
target = impersonated_credentials.Credentials(
source_credentials=credentials,
target_principal=email.strip(),
target_scopes=[],
)
id_credentials = impersonated_credentials.IDTokenCredentials(
target, target_audience=audience, include_email=True
)
id_credentials.refresh(request)
return id_credentials.token

def _token_from_user_credentials(
self, credentials, audience: str, request: Request
) -> str | None:
from google.auth import jwt

credentials.refresh(request)
token = credentials.id_token
if token and jwt.decode(token, verify=False).get("aud") != audience:
raise ValueError(
"Google user ADC ID tokens use the OAuth client ID as their audience, "
"not the requested OIDC audience. Set --oidc-audience to that client "
"ID and configure Cloudsmith to trust it, or use "
"gcloud auth application-default login "
"--impersonate-service-account=SERVICE_ACCOUNT_EMAIL."
)
return token
Loading
Loading