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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

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

## [1.18.0] - 2026-06-09

Expand All @@ -28,7 +29,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- `metadata list` filters (`--source-kind`, `--classification`) now send the enum name the v2 API expects instead of an integer, fixing an HTTP 400 on every filtered list. Valid source kinds: `unknown, system, upstream, custom, third_party`; classifications: `unknown, intrinsic, security, provenance, sbom, generic`.


## [1.17.0] - 2026-05-18

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ In Azure DevOps Pipelines, OIDC credential discovery works out of the box with n

In GitHub Actions, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the Actions runtime when the workflow requests `id-token: write` permission. See the [Cloudsmith GitHub Actions OIDC guide](https://docs.cloudsmith.com/authentication/setup-cloudsmith-to-authenticate-with-oidc-in-github-actions).

#### Generic OIDC Support (Jenkins, custom CI/CD)

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

## Configuration

There are two configuration files used by the CLI:
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 .generic import GenericDetector
from .github_actions import GitHubActionsDetector

if TYPE_CHECKING:
Expand All @@ -19,6 +20,7 @@
AzureDevOpsDetector,
GitHubActionsDetector,
AWSDetector,
GenericDetector,
Comment thread
cloudsmith-iduffy marked this conversation as resolved.
]


Expand Down
41 changes: 41 additions & 0 deletions cloudsmith_cli/core/credentials/oidc/detectors/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright 2026 Cloudsmith Ltd
"""Generic fallback OIDC detector.

Reads an OIDC token from the ``CLOUDSMITH_OIDC_TOKEN`` environment variable.
Works for Jenkins (with the credentials binding plugin), or any custom CI/CD
system that can inject an OIDC token via an environment variable.

References:
https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc
https://plugins.jenkins.io/credentials-binding/
"""

from __future__ import annotations
Comment thread
cloudsmith-iduffy marked this conversation as resolved.

import os

from .base import EnvironmentDetector

TOKEN_ENV_VAR = "CLOUDSMITH_OIDC_TOKEN"


class GenericDetector(EnvironmentDetector):
"""Generic fallback: reads the OIDC token from CLOUDSMITH_OIDC_TOKEN.

Works for Jenkins (with the credentials binding plugin), or any custom
CI/CD system that can inject an OIDC token via an environment variable.
"""

name = "Generic"

def detect(self) -> bool:
return bool((os.environ.get(TOKEN_ENV_VAR) or "").strip())

def get_token(self) -> str:
token = (os.environ.get(TOKEN_ENV_VAR) or "").strip()
if not token:
raise ValueError(
f"Generic OIDC detector selected but {TOKEN_ENV_VAR} is not "
"set. Set it to the OIDC JWT to exchange for a Cloudsmith token."
)
return token
72 changes: 72 additions & 0 deletions cloudsmith_cli/core/tests/test_generic_detector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests for the generic fallback 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.generic import GenericDetector


@pytest.fixture
def generic_env():
env = {
"CLOUDSMITH_OIDC_TOKEN": "the-jwt",
}
with mock.patch.dict("os.environ", env, clear=True):
yield env


class TestDetect:
def test_detects_when_token_present(self, generic_env):
detector = GenericDetector(context=CredentialContext())
assert detector.detect() is True

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

def test_not_detected_when_token_empty(self, generic_env):
generic_env["CLOUDSMITH_OIDC_TOKEN"] = ""
with mock.patch.dict("os.environ", generic_env, clear=True):
detector = GenericDetector(context=CredentialContext())
assert detector.detect() is False
Comment thread
cloudsmith-iduffy marked this conversation as resolved.

def test_not_detected_when_token_whitespace_only(self, generic_env):
generic_env["CLOUDSMITH_OIDC_TOKEN"] = " \t\n"
with mock.patch.dict("os.environ", generic_env, clear=True):
detector = GenericDetector(context=CredentialContext())
assert detector.detect() is False


class TestGetToken:
def test_returns_token(self, generic_env):
detector = GenericDetector(context=CredentialContext())
assert detector.get_token() == "the-jwt"

def test_strips_surrounding_whitespace(self, generic_env):
generic_env["CLOUDSMITH_OIDC_TOKEN"] = " the-jwt\n"
with mock.patch.dict("os.environ", generic_env, clear=True):
detector = GenericDetector(context=CredentialContext())
assert detector.get_token() == "the-jwt"

def test_raises_when_token_missing(self):
with mock.patch.dict("os.environ", {}, clear=True):
detector = GenericDetector(context=CredentialContext())
with pytest.raises(ValueError, match="CLOUDSMITH_OIDC_TOKEN"):
detector.get_token()

def test_raises_when_token_whitespace_only(self, generic_env):
generic_env["CLOUDSMITH_OIDC_TOKEN"] = " "
with mock.patch.dict("os.environ", generic_env, clear=True):
detector = GenericDetector(context=CredentialContext())
with pytest.raises(ValueError, match="CLOUDSMITH_OIDC_TOKEN"):
detector.get_token()


class TestIntegration:
def test_detect_environment_selects_generic(self, generic_env):
detector = detect_environment(CredentialContext())
assert isinstance(detector, GenericDetector)
Loading