diff --git a/CHANGELOG.md b/CHANGELOG.md index 3899270f..edcc2cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Added CircleCI to OIDC credential auto-discovery. When running in CircleCI, the CLI reads the OIDC token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies. - 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. - 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. - 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. diff --git a/README.md b/README.md index 405889d3..1b144696 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ 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. +#### CircleCI OIDC Support + +In CircleCI, OIDC credential discovery works out of the box with no extra dependencies — the CLI reads the token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable that CircleCI injects into every job. The Cloudsmith OIDC provider must expect the audience CircleCI mints, which is your CircleCI organization UUID. See the [Cloudsmith CircleCI integration guide](https://docs.cloudsmith.com/integrations/integrating-with-circleci). + #### Azure DevOps OIDC Support In Azure DevOps Pipelines, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN`. Make sure `SYSTEM_ACCESSTOKEN` is mapped into the step's environment. The Cloudsmith OIDC provider must expect the audience `api://AzureADTokenExchange`, which Azure DevOps always mints (any requested audience is ignored). See the [Cloudsmith Azure DevOps integration guide](https://docs.cloudsmith.com/integrations/integrating-with-azure-devops). diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index df4694e1..d0794648 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -8,6 +8,7 @@ from .aws import AWSDetector from .azure_devops import AzureDevOpsDetector from .base import EnvironmentDetector +from .circleci import CircleCIDetector from .generic import GenericDetector from .github_actions import GitHubActionsDetector @@ -17,6 +18,7 @@ logger = logging.getLogger(__name__) _DETECTORS: list[type[EnvironmentDetector]] = [ + CircleCIDetector, AzureDevOpsDetector, GitHubActionsDetector, AWSDetector, diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/circleci.py b/cloudsmith_cli/core/credentials/oidc/detectors/circleci.py new file mode 100644 index 00000000..acc07f86 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/circleci.py @@ -0,0 +1,39 @@ +# Copyright 2026 Cloudsmith Ltd +"""CircleCI OIDC detector. + +Reads OIDC token from the ``CIRCLE_OIDC_TOKEN_V2`` or ``CIRCLE_OIDC_TOKEN`` +environment variables set by CircleCI's OIDC support. + +References: + https://circleci.com/docs/guides/permissions-authentication/openid-connect-tokens/ + https://docs.cloudsmith.com/integrations/integrating-with-circleci +""" + +from __future__ import annotations + +import os + +from .base import EnvironmentDetector + + +class CircleCIDetector(EnvironmentDetector): + """Detects CircleCI and reads OIDC token from environment variable.""" + + name = "CircleCI" + + def detect(self) -> bool: + return os.environ.get("CIRCLECI") == "true" and bool( + os.environ.get("CIRCLE_OIDC_TOKEN_V2") + or os.environ.get("CIRCLE_OIDC_TOKEN") + ) + + def get_token(self) -> str: + token = os.environ.get("CIRCLE_OIDC_TOKEN_V2") or os.environ.get( + "CIRCLE_OIDC_TOKEN" + ) + if not token: + raise ValueError( + "CircleCI detected but neither CIRCLE_OIDC_TOKEN_V2 nor " + "CIRCLE_OIDC_TOKEN is set" + ) + return token diff --git a/cloudsmith_cli/core/tests/test_circleci_detector.py b/cloudsmith_cli/core/tests/test_circleci_detector.py new file mode 100644 index 00000000..63f253f7 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_circleci_detector.py @@ -0,0 +1,76 @@ +"""Tests for the CircleCI OIDC detector.""" + +from unittest import mock + +import pytest + +from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment +from cloudsmith_cli.core.credentials.oidc.detectors.circleci import CircleCIDetector + + +@pytest.fixture +def circleci_env(): + env = { + "CIRCLECI": "true", + "CIRCLE_OIDC_TOKEN_V2": "the-v2-jwt", + "CIRCLE_OIDC_TOKEN": "the-v1-jwt", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_circleci_and_v2_token_present(self, circleci_env): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_detects_with_only_v1_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_when_circleci_flag_missing(self, circleci_env): + del circleci_env["CIRCLECI"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_any_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + del circleci_env["CIRCLE_OIDC_TOKEN"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def test_prefers_v2_token(self, circleci_env): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.get_token() == "the-v2-jwt" + + def test_falls_back_to_v1_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + assert detector.get_token() == "the-v1-jwt" + + def test_raises_when_no_token(self, circleci_env): + del circleci_env["CIRCLE_OIDC_TOKEN_V2"] + del circleci_env["CIRCLE_OIDC_TOKEN"] + with mock.patch.dict("os.environ", circleci_env, clear=True): + detector = CircleCIDetector(context=CredentialContext()) + with pytest.raises(ValueError, match="CIRCLE_OIDC_TOKEN_V2"): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_circleci(self, circleci_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, CircleCIDetector)