Skip to content

Commit fc648d2

Browse files
feat: add generic OIDC detector (#304)
Add 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. Whitespace-only values are treated as unset, and the token is stripped before use. Registered last so a dedicated environment is always preferred when present. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a30884 commit fc648d2

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
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1212

1313
- 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.
1414
- 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.
15+
- 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.
1516

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

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

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

31-
3232
## [1.17.0] - 2026-05-18
3333

3434
### Added

README.md

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

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

176+
#### Generic OIDC Support (Jenkins, custom CI/CD)
177+
178+
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).
179+
176180
## Configuration
177181

178182
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
@@ -8,6 +8,7 @@
88
from .aws import AWSDetector
99
from .azure_devops import AzureDevOpsDetector
1010
from .base import EnvironmentDetector
11+
from .generic import GenericDetector
1112
from .github_actions import GitHubActionsDetector
1213

1314
if TYPE_CHECKING:
@@ -19,6 +20,7 @@
1920
AzureDevOpsDetector,
2021
GitHubActionsDetector,
2122
AWSDetector,
23+
GenericDetector,
2224
]
2325

2426

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)