Skip to content
Draft
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,10 @@ See the [Bitbucket Pipelines OIDC documentation](https://support.atlassian.com/b

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).

#### Buildkite OIDC Support

In Buildkite, OIDC credential discovery works out of the box with no extra dependencies — the CLI requests a token for the current job through `buildkite-agent oidc request-token`. By default it requests the `cloudsmith` audience; use `--oidc-audience` or `CLOUDSMITH_OIDC_AUDIENCE` if your Cloudsmith OIDC provider expects a different audience. See the [Buildkite OIDC documentation](https://buildkite.com/docs/pipelines/security/oidc).

#### 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 Expand Up @@ -242,7 +246,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`, `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`, `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 @@ -8,6 +8,7 @@
from .aws import AWSDetector
from .azure_devops import AzureDevOpsDetector
from .bitbucket_pipelines import BitbucketPipelinesDetector
from .buildkite import BuildkiteDetector
from .circleci import CircleCIDetector
from .generic import GenericDetector
from .github_actions import GitHubActionsDetector
Expand All @@ -22,6 +23,7 @@
logger = logging.getLogger(__name__)

_DETECTORS: list[type[EnvironmentDetector]] = [
BuildkiteDetector,
CircleCIDetector,
AzureDevOpsDetector,
GitHubActionsDetector,
Expand Down
51 changes: 51 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright 2026 Cloudsmith Ltd
"""Buildkite OIDC detector.

Requests an OIDC token for the current job through the ``buildkite-agent``
command, which is available in Buildkite pipeline jobs.

References:
https://buildkite.com/docs/pipelines/security/oidc
https://buildkite.com/docs/agent/cli/reference/oidc
"""

from __future__ import annotations

import os
import subprocess

from .base import EnvironmentDetector

DEFAULT_AUDIENCE = "cloudsmith"


class BuildkiteDetector(EnvironmentDetector):
"""Detects Buildkite and requests an OIDC token from its agent."""

name = "Buildkite"
id = "buildkite"

def detect(self) -> bool:
return os.environ.get("BUILDKITE") == "true" and bool(
os.environ.get("BUILDKITE_JOB_ID")
)

def get_token(self) -> str:
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
result = subprocess.run(
[
"buildkite-agent",
"oidc",
"request-token",
"--audience",
audience,
],
capture_output=True,
check=True,
text=True,
timeout=30,
)
token = result.stdout.strip()
if not token:
raise ValueError("Buildkite agent OIDC request returned an empty token")
return token
91 changes: 91 additions & 0 deletions cloudsmith_cli/core/tests/test_buildkite_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for the Buildkite OIDC detector."""

import subprocess
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.buildkite import BuildkiteDetector


@pytest.fixture
def buildkite_env():
env = {
"BUILDKITE": "true",
"BUILDKITE_JOB_ID": "0184990a-477b-4fa8-9968-496074483cee",
}
with mock.patch.dict("os.environ", env, clear=True):
yield env


class TestDetect:
def test_detects_when_buildkite_job_present(self, buildkite_env):
detector = BuildkiteDetector(context=CredentialContext())
assert detector.detect() is True

def test_not_detected_when_unset(self):
with mock.patch.dict("os.environ", {}, clear=True):
detector = BuildkiteDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_without_buildkite_flag(self, buildkite_env):
del buildkite_env["BUILDKITE"]
with mock.patch.dict("os.environ", buildkite_env, clear=True):
detector = BuildkiteDetector(context=CredentialContext())
assert detector.detect() is False

def test_not_detected_without_job_id(self, buildkite_env):
del buildkite_env["BUILDKITE_JOB_ID"]
with mock.patch.dict("os.environ", buildkite_env, clear=True):
detector = BuildkiteDetector(context=CredentialContext())
assert detector.detect() is False


class TestGetToken:
def test_requests_token_with_default_audience(self, buildkite_env):
completed = subprocess.CompletedProcess([], 0, stdout="the-jwt\n", stderr="")
with mock.patch("subprocess.run", return_value=completed) as run:
detector = BuildkiteDetector(context=CredentialContext())

assert detector.get_token() == "the-jwt"

run.assert_called_once_with(
[
"buildkite-agent",
"oidc",
"request-token",
"--audience",
"cloudsmith",
],
capture_output=True,
check=True,
text=True,
timeout=30,
)

def test_uses_custom_audience(self, buildkite_env):
completed = subprocess.CompletedProcess([], 0, stdout="the-jwt", stderr="")
with mock.patch("subprocess.run", return_value=completed) as run:
detector = BuildkiteDetector(
context=CredentialContext(oidc_audience="custom-audience")
)

detector.get_token()

assert run.call_args.args[0][-1] == "custom-audience"

def test_raises_when_agent_returns_empty_token(self, buildkite_env):
completed = subprocess.CompletedProcess([], 0, stdout="\n", stderr="")
with mock.patch("subprocess.run", return_value=completed):
detector = BuildkiteDetector(context=CredentialContext())

with pytest.raises(ValueError, match="empty token"):
detector.get_token()


class TestIntegration:
def test_detect_environment_selects_buildkite(self, buildkite_env):
detector = detect_environment(CredentialContext())
assert isinstance(detector, BuildkiteDetector)
Loading