Skip to content

Commit bf7e041

Browse files
Merge remote-tracking branch 'origin/master' into iduffy/circleci
* origin/master: feat: add generic OIDC detector (#304) # Conflicts: # cloudsmith_cli/core/credentials/oidc/detectors/__init__.py
2 parents c0f0b2a + fc648d2 commit bf7e041

5 files changed

Lines changed: 120 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1313
- Added CircleCI to OIDC credential auto-discovery. When running in CircleCI, the CLI reads the OIDC token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1414
- 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.
1515
- 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.
16+
- 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.
1617

1718
## [1.18.0] - 2026-06-09
1819

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

3031
- `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`.
3132

32-
3333
## [1.17.0] - 2026-05-18
3434

3535
### Added

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ In Azure DevOps Pipelines, OIDC credential discovery works out of the box with n
177177

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

180+
#### Generic OIDC Support (Jenkins, custom CI/CD)
181+
182+
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).
183+
180184
## Configuration
181185

182186
There are two configuration files used by the CLI:

cloudsmith_cli/core/credentials/oidc/detectors/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .azure_devops import AzureDevOpsDetector
1010
from .base import EnvironmentDetector
1111
from .circleci import CircleCIDetector
12+
from .generic import GenericDetector
1213
from .github_actions import GitHubActionsDetector
1314

1415
if TYPE_CHECKING:
@@ -21,6 +22,7 @@
2122
AzureDevOpsDetector,
2223
GitHubActionsDetector,
2324
AWSDetector,
25+
GenericDetector,
2426
]
2527

2628

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Copyright 2026 Cloudsmith Ltd
2+
"""Generic fallback OIDC detector.
3+
4+
Reads an OIDC token from the ``CLOUDSMITH_OIDC_TOKEN`` environment variable.
5+
Works for Jenkins (with the credentials binding plugin), or any custom CI/CD
6+
system that can inject an OIDC token via an environment variable.
7+
8+
References:
9+
https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc
10+
https://plugins.jenkins.io/credentials-binding/
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import os
16+
17+
from .base import EnvironmentDetector
18+
19+
TOKEN_ENV_VAR = "CLOUDSMITH_OIDC_TOKEN"
20+
21+
22+
class GenericDetector(EnvironmentDetector):
23+
"""Generic fallback: reads the OIDC token from CLOUDSMITH_OIDC_TOKEN.
24+
25+
Works for Jenkins (with the credentials binding plugin), or any custom
26+
CI/CD system that can inject an OIDC token via an environment variable.
27+
"""
28+
29+
name = "Generic"
30+
31+
def detect(self) -> bool:
32+
return bool((os.environ.get(TOKEN_ENV_VAR) or "").strip())
33+
34+
def get_token(self) -> str:
35+
token = (os.environ.get(TOKEN_ENV_VAR) or "").strip()
36+
if not token:
37+
raise ValueError(
38+
f"Generic OIDC detector selected but {TOKEN_ENV_VAR} is not "
39+
"set. Set it to the OIDC JWT to exchange for a Cloudsmith token."
40+
)
41+
return token
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for the generic fallback OIDC detector."""
2+
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from cloudsmith_cli.core.credentials.models import CredentialContext
8+
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
9+
from cloudsmith_cli.core.credentials.oidc.detectors.generic import GenericDetector
10+
11+
12+
@pytest.fixture
13+
def generic_env():
14+
env = {
15+
"CLOUDSMITH_OIDC_TOKEN": "the-jwt",
16+
}
17+
with mock.patch.dict("os.environ", env, clear=True):
18+
yield env
19+
20+
21+
class TestDetect:
22+
def test_detects_when_token_present(self, generic_env):
23+
detector = GenericDetector(context=CredentialContext())
24+
assert detector.detect() is True
25+
26+
def test_not_detected_when_unset(self):
27+
with mock.patch.dict("os.environ", {}, clear=True):
28+
detector = GenericDetector(context=CredentialContext())
29+
assert detector.detect() is False
30+
31+
def test_not_detected_when_token_empty(self, generic_env):
32+
generic_env["CLOUDSMITH_OIDC_TOKEN"] = ""
33+
with mock.patch.dict("os.environ", generic_env, clear=True):
34+
detector = GenericDetector(context=CredentialContext())
35+
assert detector.detect() is False
36+
37+
def test_not_detected_when_token_whitespace_only(self, generic_env):
38+
generic_env["CLOUDSMITH_OIDC_TOKEN"] = " \t\n"
39+
with mock.patch.dict("os.environ", generic_env, clear=True):
40+
detector = GenericDetector(context=CredentialContext())
41+
assert detector.detect() is False
42+
43+
44+
class TestGetToken:
45+
def test_returns_token(self, generic_env):
46+
detector = GenericDetector(context=CredentialContext())
47+
assert detector.get_token() == "the-jwt"
48+
49+
def test_strips_surrounding_whitespace(self, generic_env):
50+
generic_env["CLOUDSMITH_OIDC_TOKEN"] = " the-jwt\n"
51+
with mock.patch.dict("os.environ", generic_env, clear=True):
52+
detector = GenericDetector(context=CredentialContext())
53+
assert detector.get_token() == "the-jwt"
54+
55+
def test_raises_when_token_missing(self):
56+
with mock.patch.dict("os.environ", {}, clear=True):
57+
detector = GenericDetector(context=CredentialContext())
58+
with pytest.raises(ValueError, match="CLOUDSMITH_OIDC_TOKEN"):
59+
detector.get_token()
60+
61+
def test_raises_when_token_whitespace_only(self, generic_env):
62+
generic_env["CLOUDSMITH_OIDC_TOKEN"] = " "
63+
with mock.patch.dict("os.environ", generic_env, clear=True):
64+
detector = GenericDetector(context=CredentialContext())
65+
with pytest.raises(ValueError, match="CLOUDSMITH_OIDC_TOKEN"):
66+
detector.get_token()
67+
68+
69+
class TestIntegration:
70+
def test_detect_environment_selects_generic(self, generic_env):
71+
detector = detect_environment(CredentialContext())
72+
assert isinstance(detector, GenericDetector)

0 commit comments

Comments
 (0)