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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
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 @@ -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

Expand All @@ -17,6 +18,7 @@
logger = logging.getLogger(__name__)

_DETECTORS: list[type[EnvironmentDetector]] = [
CircleCIDetector,
AzureDevOpsDetector,
GitHubActionsDetector,
AWSDetector,
Expand Down
39 changes: 39 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/circleci.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
cloudsmith-iduffy marked this conversation as resolved.

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
76 changes: 76 additions & 0 deletions cloudsmith_cli/core/tests/test_circleci_detector.py
Original file line number Diff line number Diff line change
@@ -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)
Loading