diff --git a/CHANGELOG.md b/CHANGELOG.md index e39c5331..8cd38954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9be10072..4f2f2cd2 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 @@ -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__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: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index a741a426..810c08b5 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -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 @@ -30,6 +31,7 @@ BitbucketPipelinesDetector, GitLabCIDetector, AWSDetector, + GCPDetector, GenericDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/gcp.py b/cloudsmith_cli/core/credentials/oidc/detectors/gcp.py new file mode 100644 index 00000000..c209a784 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/gcp.py @@ -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 + + 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 diff --git a/cloudsmith_cli/core/tests/test_gcp_detector.py b/cloudsmith_cli/core/tests/test_gcp_detector.py new file mode 100644 index 00000000..233cf3c4 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_gcp_detector.py @@ -0,0 +1,393 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for Google Cloud discovery and ID-token generation using the real SDK.""" + +import json +import sys +from unittest import mock +from urllib.parse import parse_qs + +import httpretty +import jwt +import pytest +from google.auth import compute_engine, exceptions, impersonated_credentials +from google.oauth2 import credentials as user_creds +from google.oauth2 import service_account + +from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.oidc.detectors import ( + AWSDetector, + detect_environment, + disabled_detectors_from_env, + registered_detectors, +) +from cloudsmith_cli.core.credentials.oidc.detectors.gcp import GCPDetector +from cloudsmith_cli.core.credentials.providers.oidc_provider import OidcProvider + +METADATA_GET = "google.auth.compute_engine._metadata.get" +TOKEN_URI = "https://oauth2.googleapis.com/token" +EMAIL = "builder@example.iam.gserviceaccount.com" +IAM_URL = f"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{EMAIL}:generateIdToken" + + +def make_detector(**ctx): + return GCPDetector(context=CredentialContext(**ctx)) + + +def make_token(audience="cloudsmith"): + return jwt.encode( + {"aud": audience, "iss": "https://accounts.google.com", "exp": 2000000000}, + key="", + algorithm="none", + ) + + +def service_credentials(): + signer = mock.Mock() + signer.key_id = "test-key" + signer.sign.return_value = b"test-signature" + return service_account.Credentials(signer, EMAIL, TOKEN_URI) + + +class TestDetect: + def test_not_detected_when_google_auth_missing(self): + with mock.patch.dict(sys.modules, {"google.auth": None}): + assert make_detector().detect() is False + + @pytest.mark.parametrize( + "error", + [ + exceptions.DefaultCredentialsError("invalid ADC"), + exceptions.TransportError("unreachable"), + ], + ) + def test_not_detected_on_google_auth_error(self, error): + with ( + mock.patch("google.auth.default", side_effect=error), + mock.patch(METADATA_GET) as metadata, + ): + assert make_detector().detect() is False + metadata.assert_not_called() + + def test_unsupported_adc_does_not_shadow_generic_detector(self, monkeypatch): + monkeypatch.setenv("CLOUDSMITH_OIDC_TOKEN", "generic-token") + with mock.patch("google.auth.default", return_value=(mock.Mock(), "proj")): + detector = detect_environment( + CredentialContext(oidc_detector_order="gcp,generic") + ) + assert detector is not None + assert detector.id == "generic" + + def test_incompatible_user_adc_does_not_shadow_generic_detector(self, monkeypatch): + monkeypatch.setenv("CLOUDSMITH_OIDC_TOKEN", "generic-token") + credentials = user_creds.Credentials(None, client_id="oauth-client-id") + with mock.patch("google.auth.default", return_value=(credentials, None)): + detector = detect_environment( + CredentialContext(oidc_detector_order="gcp,generic") + ) + assert detector is not None + assert detector.id == "generic" + assert detector.get_token() == "generic-token" + + +class TestGetTokenMetadata: + @pytest.mark.parametrize("audience", [None, "custom-audience"]) + def test_uses_detected_credentials_and_requests_full_token(self, audience): + with ( + mock.patch( + "google.auth.default", + return_value=(compute_engine.Credentials(), "proj"), + ) as default, + mock.patch(METADATA_GET, return_value="meta-jwt\n") as get, + ): + detector = make_detector(oidc_audience=audience) + assert detector.detect() + assert detector.get_token() == "meta-jwt" + default.assert_called_once() + assert get.call_args.args[1] == "instance/service-accounts/default/identity" + assert get.call_args.kwargs["params"] == { + "audience": audience or "cloudsmith", + "format": "full", + } + + @pytest.mark.parametrize("token", ["", " ", None, {"token": "unexpected-json"}]) + def test_rejects_empty_or_invalid_metadata_token(self, token): + with ( + mock.patch( + "google.auth.default", + return_value=(compute_engine.Credentials(), None), + ), + mock.patch(METADATA_GET, return_value=token), + pytest.raises(ValueError, match="could not mint"), + ): + make_detector().get_token() + + @pytest.mark.parametrize("status", [None, 403, 500]) + def test_does_not_fall_back_on_metadata_failure(self, status): + error = exceptions.TransportError("metadata failed", mock.Mock(status=status)) + with ( + mock.patch( + "google.auth.default", + return_value=(compute_engine.Credentials(), None), + ), + mock.patch(METADATA_GET, side_effect=error) as get, + pytest.raises(exceptions.TransportError), + ): + make_detector().get_token() + get.assert_called_once() + + @httpretty.activate + def test_cloud_build_missing_identity_endpoint_uses_iam(self, monkeypatch): + # Newer google-auth starts an unrelated background policy lookup after + # refreshing compute credentials; keep this token-flow test synchronous. + monkeypatch.setattr( + compute_engine.Credentials, + "_is_regional_access_boundary_lookup_required", + lambda self: False, + raising=False, + ) + metadata = "http://metadata.google.internal/computeMetadata/v1/" + httpretty.register_uri( + httpretty.GET, + metadata + "universe/universe-domain", + body="googleapis.com", + content_type="text/plain", + ) + httpretty.register_uri( + httpretty.GET, + metadata + "instance/service-accounts/default/identity", + status=404, + ) + httpretty.register_uri( + httpretty.GET, + metadata + "instance/service-accounts/default/email", + body=EMAIL, + content_type="text/plain", + ) + httpretty.register_uri( + httpretty.GET, + metadata + "instance/service-accounts/default/", + body=json.dumps({"email": EMAIL, "scopes": []}), + content_type="application/json", + ) + for account in ("default", EMAIL): + httpretty.register_uri( + httpretty.GET, + metadata + f"instance/service-accounts/{account}/token", + body=json.dumps( + {"access_token": "metadata-access-token", "expires_in": 3600} + ), + content_type="application/json", + ) + httpretty.register_uri( + httpretty.POST, + IAM_URL, + body=json.dumps({"token": make_token("custom-audience")}), + content_type="application/json", + ) + with mock.patch( + "google.auth.default", return_value=(compute_engine.Credentials(), None) + ): + assert make_detector( + oidc_audience="custom-audience" + ).get_token() == make_token("custom-audience") + request = httpretty.last_request() + assert request.headers["Authorization"] == "Bearer metadata-access-token" + assert json.loads(request.body) == { + "audience": "custom-audience", + "includeEmail": True, + "delegates": None, + } + + +class TestGetTokenUserCredentials: + @pytest.mark.parametrize("audience", ["cloudsmith", "oauth-client-id"]) + def test_does_not_silently_ignore_requested_audience(self, audience): + credentials = mock.Mock(spec=user_creds.Credentials) + credentials.client_id = "oauth-client-id" + credentials.id_token = make_token("oauth-client-id") + with mock.patch("google.auth.default", return_value=(credentials, None)): + detector = make_detector(oidc_audience=audience) + assert detector.detect() is (audience == "oauth-client-id") + if audience == "oauth-client-id": + assert detector.get_token() == credentials.id_token + else: + with pytest.raises(ValueError, match="OAuth client ID"): + detector.get_token() + credentials.refresh.assert_called_once() + + def test_rejects_missing_user_id_token(self): + credentials = mock.Mock(spec=user_creds.Credentials) + credentials.id_token = None + with ( + mock.patch("google.auth.default", return_value=(credentials, None)), + pytest.raises(ValueError, match="could not mint"), + ): + make_detector().get_token() + + def test_refresh_error_is_preserved(self): + credentials = mock.Mock(spec=user_creds.Credentials) + credentials.refresh.side_effect = exceptions.RefreshError("reauthenticate") + with ( + mock.patch("google.auth.default", return_value=(credentials, None)), + pytest.raises(exceptions.RefreshError, match="reauthenticate"), + ): + make_detector().get_token() + + +class TestGetTokenServiceAccount: + @httpretty.activate + def test_impersonated_adc_preserves_target_audience_and_delegates( + self, tmp_path, monkeypatch + ): + delegates = ["delegate@example.iam.gserviceaccount.com"] + credentials_file = tmp_path / "adc.json" + credentials_file.write_text( + json.dumps( + { + "type": "impersonated_service_account", + "service_account_impersonation_url": IAM_URL.replace( + ":generateIdToken", ":generateAccessToken" + ), + "source_credentials": { + "type": "authorized_user", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token", + }, + "delegates": delegates, + } + ) + ) + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(credentials_file)) + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project") + httpretty.register_uri( + httpretty.POST, + TOKEN_URI, + body=json.dumps( + {"access_token": "source-access-token", "expires_in": 3600} + ), + content_type="application/json", + ) + httpretty.register_uri( + httpretty.POST, + IAM_URL, + body=json.dumps({"token": make_token("custom-audience")}), + content_type="application/json", + ) + detector = make_detector(oidc_audience="custom-audience") + assert detector.detect() + assert detector.get_token() == make_token("custom-audience") + request = httpretty.last_request() + assert request.headers["Authorization"] == "Bearer source-access-token" + assert json.loads(request.body) == { + "audience": "custom-audience", + "includeEmail": True, + "delegates": delegates, + } + + @httpretty.activate + def test_resolved_key_credentials_work_without_rediscovering_adc(self): + httpretty.register_uri( + httpretty.POST, + TOKEN_URI, + body=json.dumps({"id_token": make_token("custom-audience")}), + content_type="application/json", + ) + with ( + mock.patch( + "google.auth.default", return_value=(service_credentials(), "proj") + ) as default, + mock.patch.dict("os.environ", {}, clear=True), + ): + detector = make_detector(oidc_audience="custom-audience") + assert detector.detect() + assert detector.get_token() == make_token("custom-audience") + default.assert_called_once() + assertion = parse_qs(httpretty.last_request().body.decode())["assertion"][0] + claims = jwt.decode(assertion, options={"verify_signature": False}) + assert claims["target_audience"] == "custom-audience" + assert claims["iss"] == EMAIL + + @httpretty.activate + def test_iam_permission_failure_is_preserved(self): + credentials = impersonated_credentials.Credentials( + source_credentials=user_creds.Credentials(token="source-access-token"), + target_principal=EMAIL, + target_scopes=[], + ) + httpretty.register_uri( + httpretty.POST, + IAM_URL, + status=403, + body=json.dumps({"error": "permission denied"}), + content_type="application/json", + ) + with ( + mock.patch("google.auth.default", return_value=(credentials, None)), + pytest.raises(exceptions.RefreshError, match="permission denied"), + ): + make_detector().get_token() + + +class TestRegistration: + def test_unsupported_credentials_cannot_be_used_directly(self): + with ( + mock.patch("google.auth.default", return_value=(mock.Mock(), None)), + pytest.raises(TypeError, match="cannot mint an OIDC ID token"), + ): + make_detector().get_token() + + def test_registered_after_aws(self): + detectors = registered_detectors() + assert detectors.index(GCPDetector) == detectors.index(AWSDetector) + 1 + + def test_disabled_gcp_does_not_resolve_adc(self): + with mock.patch("google.auth.default") as default: + assert ( + detect_environment( + CredentialContext( + oidc_detector_order="gcp", + oidc_disabled_detectors=disabled_detectors_from_env( + {"CLOUDSMITH_OIDC_GCP_DISABLED": "true"} + ), + ) + ) + is None + ) + default.assert_not_called() + + def test_missing_dependency_reports_install_extra(self): + with ( + mock.patch.dict(sys.modules, {"google.auth": None}), + pytest.raises(ValueError, match=r"cloudsmith-cli\[gcp\]"), + ): + make_detector().get_token() + + def test_gcp_token_reaches_cloudsmith_exchange(self): + context = CredentialContext( + org="test-org", oidc_service_slug="test-service", oidc_detector_order="gcp" + ) + with ( + mock.patch( + "google.auth.default", return_value=(compute_engine.Credentials(), None) + ), + mock.patch(METADATA_GET, return_value=make_token()), + mock.patch( + "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", + return_value=None, + ), + mock.patch("cloudsmith_cli.core.credentials.oidc.cache.store_cached_token"), + mock.patch( + "cloudsmith_cli.core.credentials.oidc.exchange.exchange_oidc_token", + return_value="exchanged-token", + ) as exchange, + ): + result = OidcProvider().resolve(context) + exchange.assert_called_once_with( + context=context, + org="test-org", + service_slug="test-service", + oidc_token=make_token(), + ) + assert result is not None + assert result.api_key == "exchanged-token" diff --git a/cloudsmith_cli/core/tests/test_generic_detector.py b/cloudsmith_cli/core/tests/test_generic_detector.py index dd640b77..9702b1f2 100644 --- a/cloudsmith_cli/core/tests/test_generic_detector.py +++ b/cloudsmith_cli/core/tests/test_generic_detector.py @@ -69,6 +69,6 @@ def test_raises_when_token_whitespace_only(self, generic_env): class TestIntegration: def test_detect_environment_selects_generic(self, generic_env): detector = detect_environment( - CredentialContext(oidc_disabled_detectors=frozenset({"aws"})) + CredentialContext(oidc_disabled_detectors=frozenset({"aws", "gcp"})) ) assert isinstance(detector, GenericDetector) diff --git a/pyproject.toml b/pyproject.toml index 7b02bd6d..0fa9eac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,8 @@ dependencies = [ [project.optional-dependencies] aws = ["boto3[crt]>=1.26.0"] -all = ["boto3[crt]>=1.26.0"] +gcp = ["google-auth>=2.40.0"] +all = ["boto3[crt]>=1.26.0", "google-auth>=2.40.0"] [project.scripts] cloudsmith = "cloudsmith_cli.cli.commands.main:main" @@ -81,6 +82,7 @@ Homepage = "https://github.com/cloudsmith-io/cloudsmith-cli" [dependency-groups] dev = [ "boto3[crt]>=1.26.0", + "google-auth>=2.40.0", "pre-commit", "pylint", "pytest-cov", diff --git a/uv.lock b/uv.lock index 7b15a890..9228f606 100644 --- a/uv.lock +++ b/uv.lock @@ -547,10 +547,14 @@ dependencies = [ [package.optional-dependencies] all = [ { name = "boto3", extra = ["crt"] }, + { name = "google-auth" }, ] aws = [ { name = "boto3", extra = ["crt"] }, ] +gcp = [ + { name = "google-auth" }, +] [package.dev-dependencies] binary = [ @@ -563,6 +567,7 @@ dev = [ { name = "boto3", extra = ["crt"] }, { name = "bumpversion" }, { name = "freezegun" }, + { name = "google-auth" }, { name = "httpretty" }, { name = "pre-commit" }, { name = "pylint" }, @@ -587,6 +592,8 @@ requires-dist = [ { name = "click-spinner", specifier = ">=0.1.7" }, { name = "cloudsmith-api", specifier = ">=2.0.31,<3.0" }, { name = "cryptography", specifier = ">=50.0.0" }, + { name = "google-auth", marker = "extra == 'all'", specifier = ">=2.40.0" }, + { name = "google-auth", marker = "extra == 'gcp'", specifier = ">=2.40.0" }, { name = "json5", specifier = ">=0.9.0" }, { name = "keyring", specifier = ">=25.4.1" }, { name = "keyrings-alt", specifier = ">=5.0.2" }, @@ -603,7 +610,7 @@ requires-dist = [ { name = "tomlkit", specifier = ">=0.15.0" }, { name = "urllib3", specifier = ">=2.5" }, ] -provides-extras = ["aws", "all"] +provides-extras = ["aws", "gcp", "all"] [package.metadata.requires-dev] binary = [ @@ -616,6 +623,7 @@ dev = [ { name = "boto3", extras = ["crt"], specifier = ">=1.26.0" }, { name = "bumpversion" }, { name = "freezegun" }, + { name = "google-auth", specifier = ">=2.40.0" }, { name = "httpretty" }, { name = "pre-commit" }, { name = "pylint" }, @@ -868,7 +876,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -896,6 +904,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] +[[package]] +name = "google-auth" +version = "2.59.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/0b/9b4e806ebcd29701b5193a162dd9906c4c5a16cbde8476461622d2bfa70e/google_auth-2.59.0.tar.gz", hash = "sha256:eb32f44f89f6b577947ebee5887c1db46e6b1a278889ba369a88179643f32240", size = 381025, upload-time = "2026-09-28T21:49:59.047Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/fd/4208e3ac1d7df39f4e8b044a3943eb8a5a99aaf8699285bf780f614a0b30/google_auth-2.59.0-py3-none-any.whl", hash = "sha256:43e47b1d3067fb90d77bd9ba21f139bb0577b3f2b9f3e2516fc00f37435efcad", size = 262487, upload-time = "2026-09-28T21:49:57.428Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -985,7 +1006,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [ @@ -1148,7 +1169,7 @@ name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph" }, + { name = "altgraph", marker = "python_full_version < '3.12' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ @@ -1339,6 +1360,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2021,8 +2063,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version < '3.12' or sys_platform != 'win32'" }, + { name = "jeepney", marker = "python_full_version < '3.12' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [