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 @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- 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.
- Added GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (configured via `id_tokens` in `.gitlab-ci.yml`) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
- Added controls for OIDC detector selection. Set `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED=true` to skip a specific detector (only the literal `true` disables), or use `--oidc-detector-order` (env var `CLOUDSMITH_OIDC_DETECTOR_ORDER`) with a comma-separated list of detector ids to override which detectors are considered and the order they are tried in. When both are set, disable flags take precedence over the order list. Both controls can also be set in `config.ini` via the `oidc_detector_order` and `oidc_disabled_detectors` keys (the latter additive with the `*_DISABLED` env vars). Unknown ids in the order, or controls that leave no detector enabled, are surfaced as a warning. Detector ids: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`.

## [1.18.0] - 2026-06-09

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ See the [Cloudsmith GitLab CI/CD integration guide](https://docs.cloudsmith.com/

As a fallback for environments without a dedicated detector (for example Jenkins with the [credentials binding plugin](https://plugins.jenkins.io/credentials-binding/), or any custom CI/CD system), set the `CLOUDSMITH_OIDC_TOKEN` environment variable to an OIDC JWT and the CLI will exchange it for a Cloudsmith access token. This detector runs last, so a dedicated environment is always preferred when present. See the [Cloudsmith Jenkins OIDC guide](https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc).

#### Controlling OIDC Detector Selection

By default the CLI tries each detector in a fixed priority order and uses the first that matches. Two controls let you override this when a detector matches an environment you don't want it to (for example, the AWS detector matching ambient instance credentials):

- **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.
Comment thread
BartoszBlizniak marked this conversation as resolved.
- **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`.

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

```ini
[default]
oidc_detector_order = github, aws
oidc_disabled_detectors = aws, gitlab
```

The `--oidc-detector-order` flag (or the `CLOUDSMITH_OIDC_DETECTOR_ORDER` environment variable) overrides the `oidc_detector_order` config value. The `oidc_disabled_detectors` config key is additive with the per-detector `CLOUDSMITH_OIDC_<DETECTOR>_DISABLED` environment variables — a detector disabled by either is skipped.

## Configuration

There are two configuration files used by the CLI:
Expand Down
22 changes: 22 additions & 0 deletions cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ class Default(SectionSchema):
oidc_audience = ConfigParam(name="oidc_audience", type=str)
oidc_org = ConfigParam(name="oidc_org", type=str)
oidc_service_slug = ConfigParam(name="oidc_service_slug", type=str)
oidc_detector_order = ConfigParam(name="oidc_detector_order", type=str)
oidc_disabled_detectors = ConfigParam(name="oidc_disabled_detectors", type=str)
metadata_failure_mode = ConfigParam(name="metadata_failure_mode", type=str)

@matches_section("profile:*")
Expand Down Expand Up @@ -489,6 +491,26 @@ def oidc_discovery_disabled(self, value):
"oidc_discovery_disabled", bool(value) if value is not None else False
)

@property
def oidc_detector_order(self):
"""Get value for the OIDC detector evaluation order."""
return self._get_option("oidc_detector_order")

@oidc_detector_order.setter
def oidc_detector_order(self, value):
"""Set value for the OIDC detector evaluation order."""
self._set_option("oidc_detector_order", value)

@property
def oidc_disabled_detectors(self):
"""Get the comma-separated OIDC detector ids disabled via config."""
return self._get_option("oidc_disabled_detectors")

@oidc_disabled_detectors.setter
def oidc_disabled_detectors(self, value):
"""Set the comma-separated OIDC detector ids disabled via config."""
self._set_option("oidc_disabled_detectors", value)

@property
def metadata_failure_mode(self):
"""Get value for push-time metadata failure mode."""
Expand Down
67 changes: 67 additions & 0 deletions cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""CLI - Decorators."""

import functools
import os

import click
from click.core import ParameterSource
Expand All @@ -10,6 +11,10 @@
from ..core.api.init import initialise_api as _initialise_api
from ..core.credentials.chain import CredentialProviderChain
from ..core.credentials.models import CredentialContext
from ..core.credentials.oidc.detectors import (
disabled_detectors_from_env,
registered_detectors,
)
from ..core.mcp import server
from ..core.rest import create_requests_session as _create_session
from . import config, utils
Expand Down Expand Up @@ -307,6 +312,50 @@ def wrapper(ctx, *args, **kwargs):
return wrapper


def _parse_detector_ids(value):
"""Split a comma-separated detector id string into stripped, lowercased ids."""
return [
token.strip().lower() for token in (value or "").split(",") if token.strip()
]


def _config_disabled_detectors(value):
"""Detector ids disabled via the config-file ``oidc_disabled_detectors`` key."""
return frozenset(_parse_detector_ids(value))


def _warn_on_oidc_detector_controls(order, disabled):
"""Warn when the OIDC detector order/disable controls look misconfigured.

Advisory only — detection itself is resolved in the core detectors module;
these warnings surface user-facing mistakes (a typo'd id, or controls that
leave nothing enabled) without aborting the credential fallback chain.
"""
valid_ids = [detector_cls.id for detector_cls in registered_detectors()]
order_ids = _parse_detector_ids(order)

unknown_ids = [
identifier for identifier in order_ids if identifier not in valid_ids
]
if unknown_ids:
click.secho(
"OIDC: ignoring unknown detector id(s) in --oidc-detector-order: "
f"{', '.join(unknown_ids)}. Valid ids: {', '.join(valid_ids)}.",
fg="yellow",
err=True,
)

candidate_ids = [i for i in order_ids if i in valid_ids] if order_ids else valid_ids
enabled_ids = [i for i in candidate_ids if i not in disabled]
if (order_ids or disabled) and not enabled_ids:
click.secho(
"OIDC: no detectors are enabled after applying the detector "
"order/disable controls; OIDC auto-discovery will be skipped.",
fg="yellow",
err=True,
)


def resolve_credentials(f):
"""Resolve credentials via the provider chain. Depends on initialise_session."""

Expand All @@ -332,6 +381,12 @@ def resolve_credentials(f):
envvar="CLOUDSMITH_OIDC_DISCOVERY_DISABLED",
help="Disable OIDC auto-discovery.",
)
@click.option(
"--oidc-detector-order",
envvar="CLOUDSMITH_OIDC_DETECTOR_ORDER",
help="Comma-separated OIDC detector ids to control which detectors "
"are considered and the order they are tried in.",
)
@click.pass_context
@functools.wraps(f)
def wrapper(ctx, *args, **kwargs):
Expand All @@ -342,6 +397,7 @@ def wrapper(ctx, *args, **kwargs):
oidc_org = kwargs.pop("oidc_org")
oidc_service_slug = kwargs.pop("oidc_service_slug")
oidc_discovery_disabled = _pop_boolean_flag(kwargs, "oidc_discovery_disabled")
oidc_detector_order = kwargs.pop("oidc_detector_order")

if oidc_audience:
opts.oidc_audience = oidc_audience
Expand All @@ -351,6 +407,15 @@ def wrapper(ctx, *args, **kwargs):
opts.oidc_service_slug = oidc_service_slug
if oidc_discovery_disabled:
opts.oidc_discovery_disabled = oidc_discovery_disabled
if oidc_detector_order:
opts.oidc_detector_order = oidc_detector_order

oidc_disabled_detectors = disabled_detectors_from_env(
os.environ
) | _config_disabled_detectors(opts.oidc_disabled_detectors)
_warn_on_oidc_detector_controls(
opts.oidc_detector_order, oidc_disabled_detectors
)

context = CredentialContext(
session=opts.session,
Expand All @@ -365,6 +430,8 @@ def wrapper(ctx, *args, **kwargs):
oidc_org=opts.oidc_org,
oidc_service_slug=opts.oidc_service_slug,
oidc_discovery_disabled=opts.oidc_discovery_disabled,
oidc_detector_order=opts.oidc_detector_order,
oidc_disabled_detectors=oidc_disabled_detectors,
)

chain = CredentialProviderChain()
Expand Down
77 changes: 77 additions & 0 deletions cloudsmith_cli/cli/tests/test_oidc_detector_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright 2026 Cloudsmith Ltd
"""Tests for config-file + CLI-layer OIDC detector controls."""

import pytest

from ..config import ConfigReader, Options
from ..decorators import _config_disabled_detectors, _warn_on_oidc_detector_controls


@pytest.fixture
def config_file(tmp_path):
"""Yield a writer for a temporary config.ini, restoring reader state."""
original_files = list(ConfigReader.config_files)

def write(body):
path = tmp_path / "config.ini"
path.write_text(body)
return str(path)

yield write
ConfigReader.config_files = original_files


class TestConfigFileControls:
def test_detector_order_is_read_from_config(self, config_file):
path = config_file("[default]\noidc_detector_order = github, aws\n")
opts = Options()
opts.load_config_file(path)
assert opts.oidc_detector_order == "github, aws"

def test_disabled_detectors_is_read_from_config(self, config_file):
path = config_file("[default]\noidc_disabled_detectors = aws,gitlab\n")
opts = Options()
opts.load_config_file(path)
assert opts.oidc_disabled_detectors == "aws,gitlab"


class TestConfigDisabledDetectors:
def test_parses_comma_separated_ids(self):
assert _config_disabled_detectors("aws, gitlab") == frozenset({"aws", "gitlab"})

def test_lowercases_and_strips(self):
assert _config_disabled_detectors(" AWS , GitLab ") == frozenset(
{"aws", "gitlab"}
)

def test_empty_or_none_is_empty_set(self):
assert _config_disabled_detectors(None) == frozenset()
assert _config_disabled_detectors(" ") == frozenset()


class TestDetectorControlWarnings:
def test_unknown_id_warns(self, capsys):
_warn_on_oidc_detector_controls("github,nope", frozenset())
err = capsys.readouterr().err
assert "nope" in err
assert "github" not in err.split("Valid ids", 1)[0]

def test_no_warning_when_all_ids_known(self, capsys):
_warn_on_oidc_detector_controls("github,aws", frozenset())
assert capsys.readouterr().err == ""

def test_no_warning_without_controls(self, capsys):
_warn_on_oidc_detector_controls(None, frozenset())
assert capsys.readouterr().err == ""

def test_warns_when_no_detectors_enabled_via_order(self, capsys):
_warn_on_oidc_detector_controls("nope", frozenset())
assert "no detectors are enabled" in capsys.readouterr().err.lower()

def test_warns_when_order_fully_disabled(self, capsys):
_warn_on_oidc_detector_controls("aws", frozenset({"aws"}))
assert "no detectors are enabled" in capsys.readouterr().err.lower()

def test_no_enabled_warning_in_normal_case(self, capsys):
_warn_on_oidc_detector_controls("github,aws", frozenset())
assert "no detectors are enabled" not in capsys.readouterr().err.lower()
2 changes: 2 additions & 0 deletions cloudsmith_cli/core/credentials/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class CredentialContext:
oidc_org: str | None = None
oidc_service_slug: str | None = None
oidc_discovery_disabled: bool = False
oidc_detector_order: str | None = None
oidc_disabled_detectors: frozenset[str] = frozenset()


@dataclass
Expand Down
70 changes: 69 additions & 1 deletion cloudsmith_cli/core/credentials/oidc/detectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from .gitlab_ci import GitLabCIDetector

if TYPE_CHECKING:
from collections.abc import Mapping

from ... import CredentialContext

logger = logging.getLogger(__name__)
Expand All @@ -30,11 +32,77 @@
]


def registered_detectors() -> list[type[EnvironmentDetector]]:
"""Return the registered OIDC detectors in their default priority order."""
return list(_DETECTORS)


def disable_env_var(identifier: str) -> str:
"""The environment variable that disables the detector with this id."""
return f"CLOUDSMITH_OIDC_{identifier.upper()}_DISABLED"


def _is_disabled_value(value: str | None) -> bool:
"""Only the literal string ``true`` (case-insensitive) disables a detector."""
return (value or "").strip().lower() == "true"


def disabled_detectors_from_env(environ: Mapping[str, str]) -> frozenset[str]:
"""Detector ids disabled via their CLOUDSMITH_OIDC_<ID>_DISABLED variable."""
return frozenset(
detector_cls.id
for detector_cls in _DETECTORS
if _is_disabled_value(environ.get(disable_env_var(detector_cls.id)))
)


def _ordered_detectors(order: str | None) -> list[type[EnvironmentDetector]]:
"""Detectors in evaluation order, honouring an explicit order string.

When ``order`` is unset/empty the default registration order is used.
Otherwise only the listed ids are considered, in the listed order;
unknown ids are logged and skipped, and duplicate ids keep their first
position so each detector is evaluated at most once.
"""
raw_order = (order or "").strip()
if not raw_order:
return list(_DETECTORS)

detectors_by_id = {detector_cls.id: detector_cls for detector_cls in _DETECTORS}
ordered: dict[str, type[EnvironmentDetector]] = {}
for token in raw_order.split(","):
identifier = token.strip().lower()
if not identifier or identifier in ordered:
continue
detector_cls = detectors_by_id.get(identifier)
if detector_cls is None:
logger.debug("Ignoring unknown OIDC detector id: %s", identifier)
Comment thread
BartoszBlizniak marked this conversation as resolved.
continue
ordered[identifier] = detector_cls
return list(ordered.values())


def _enabled_detectors(
order: str | None, disabled: frozenset[str]
) -> list[type[EnvironmentDetector]]:
"""Ordered detectors with disabled ones removed (disable always wins)."""
return [
detector_cls
for detector_cls in _ordered_detectors(order)
if detector_cls.id not in disabled
]


def detect_environment(
context: CredentialContext,
) -> EnvironmentDetector | None:
"""Try each detector in order, returning the first that matches."""
for detector_cls in _DETECTORS:
enabled = _enabled_detectors(
context.oidc_detector_order, context.oidc_disabled_detectors
)
if not enabled:
logger.debug("No OIDC detectors enabled after applying order/disable controls")
Comment thread
BartoszBlizniak marked this conversation as resolved.
for detector_cls in enabled:
detector = detector_cls(context=context)
try:
if detector.detect():
Expand Down
1 change: 1 addition & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class AWSDetector(EnvironmentDetector):
"""Detects AWS environments and obtains a JWT via STS GetWebIdentityToken."""

name = "AWS"
id = "aws"

def __init__(self, context):
super().__init__(context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class AzureDevOpsDetector(EnvironmentDetector):
"""Detects Azure DevOps and fetches an OIDC token via HTTP POST."""

name = "Azure DevOps"
id = "azure_devops"

def detect(self) -> bool:
return bool(os.environ.get("SYSTEM_OIDCREQUESTURI")) and bool(
Expand Down
1 change: 1 addition & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class EnvironmentDetector:
"""Base class for OIDC environment detectors."""

name: str = "base"
id: str = "base"

def __init__(self, context: CredentialContext):
self.context = context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class BitbucketPipelinesDetector(EnvironmentDetector):
"""Detects Bitbucket Pipelines and reads its OIDC token from environment."""

name = "Bitbucket Pipelines"
id = "bitbucket"

def detect(self) -> bool:
return bool(os.environ.get("BITBUCKET_STEP_OIDC_TOKEN"))
Expand Down
Loading
Loading