diff --git a/CHANGELOG.md b/CHANGELOG.md index b867321d..3899270f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 18ea9428..405889d3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index 60695bcc..df4694e1 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -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: @@ -19,6 +20,7 @@ AzureDevOpsDetector, GitHubActionsDetector, AWSDetector, + GenericDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/generic.py b/cloudsmith_cli/core/credentials/oidc/detectors/generic.py new file mode 100644 index 00000000..e8b876d4 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/generic.py @@ -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 + +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 diff --git a/cloudsmith_cli/core/tests/test_generic_detector.py b/cloudsmith_cli/core/tests/test_generic_detector.py new file mode 100644 index 00000000..4bd694d7 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_generic_detector.py @@ -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 + + 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)