Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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. Detector ids: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`.

## [1.18.0] - 2026-06-09

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ 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. 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`.

## Configuration

There are two configuration files used by the CLI:
Expand Down
10 changes: 10 additions & 0 deletions cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,16 @@ 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 metadata_failure_mode(self):
"""Get value for push-time metadata failure mode."""
Expand Down
13 changes: 13 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,7 @@
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
from ..core.mcp import server
from ..core.rest import create_requests_session as _create_session
from . import config, utils
Expand Down Expand Up @@ -332,6 +334,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 +350,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 +360,8 @@ 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

context = CredentialContext(
session=opts.session,
Expand All @@ -365,6 +376,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=disabled_detectors_from_env(os.environ),
)

chain = CredentialProviderChain()
Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class CircleCIDetector(EnvironmentDetector):
"""Detects CircleCI and reads OIDC token from environment variable."""

name = "CircleCI"
id = "circleci"

def detect(self) -> bool:
return os.environ.get("CIRCLECI") == "true" and bool(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class GenericDetector(EnvironmentDetector):
"""

name = "Generic"
id = "generic"

def detect(self) -> bool:
return bool((os.environ.get(TOKEN_ENV_VAR) or "").strip())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class GitHubActionsDetector(EnvironmentDetector):
"""Detects GitHub Actions and fetches an OIDC token via HTTP request."""

name = "GitHub Actions"
id = "github"

def detect(self) -> bool:
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class GitLabCIDetector(EnvironmentDetector):
"""

name = "GitLab CI"
id = "gitlab"

TOKEN_ENV_VAR = "CLOUDSMITH_OIDC_TOKEN"

Expand Down
Loading
Loading