Skip to content

Commit ea65f83

Browse files
feat: add OIDC credential auto-discovery for CI/CD environments
Add a new OIDC credential provider to the credential chain that automatically detects CI/CD environments, retrieves a vendor OIDC JWT, and exchanges it for a short-lived Cloudsmith API token. Key changes: - New OidcProvider in the credential provider chain (lowest priority) - AWS environment detector using boto3/STS (optional `aws` extra) - OIDC token exchange with retry and exponential backoff - Token caching via system keyring with filesystem fallback - Keyring helpers for OIDC token storage/retrieval - `whoami` command updated to display OIDC auth source - README updated with optional dependency install instructions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 65d8c53 commit ea65f83

File tree

16 files changed

+632
-77
lines changed

16 files changed

+632
-77
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,30 @@ Or you can get the latest pre-release version from Cloudsmith:
136136
pip install --upgrade cloudsmith-cli --extra-index-url=https://dl.cloudsmith.io/public/cloudsmith/cli/python/index/
137137
```
138138

139+
### Optional Dependencies
140+
141+
The CLI supports optional extras for additional functionality:
142+
143+
#### AWS OIDC Support
144+
145+
For AWS environments (ECS, EKS, EC2), install with `aws` extra to enable automatic credential discovery:
146+
147+
```
148+
pip install cloudsmith-cli[aws]
149+
```
150+
151+
This installs `boto3[crt]` for AWS credential chain support, STS token generation, and AWS SSO compatibility.
152+
153+
#### All Optional Features
154+
155+
To install all optional dependencies:
156+
157+
```
158+
pip install cloudsmith-cli[all]
159+
```
160+
161+
**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors.
162+
139163
## Configuration
140164

141165
There are two configuration files used by the CLI:

cloudsmith_cli/cli/commands/whoami.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ def _get_api_key_source(opts):
2929
Checks in priority order matching actual resolution:
3030
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini.
3131
"""
32+
credential = getattr(opts, "credential", None)
33+
if credential:
34+
return {
35+
"configured": True,
36+
"source": credential.source_detail or credential.source_name,
37+
"source_key": credential.source_name,
38+
}
39+
3240
if not opts.api_key:
3341
return {"configured": False, "source": None, "source_key": None}
3442

@@ -42,12 +50,40 @@ def _get_api_key_source(opts):
4250
source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var"
4351
elif creds := CredentialsReader.find_existing_files():
4452
source, key = f"credentials.ini ({creds[0]})", "credentials_file"
53+
elif _is_oidc_configured():
54+
org = os.environ.get("CLOUDSMITH_ORG", "")
55+
detector_name = _get_oidc_detector_name(debug=opts.debug)
56+
if detector_name:
57+
source = f"OIDC auto-discovery: {detector_name} (org: {org})"
58+
else:
59+
source = f"OIDC auto-discovery (org: {org})"
60+
key = "oidc"
4561
else:
4662
source, key = "CLI --api-key flag", "cli_flag"
4763

4864
return {"configured": True, "source": source, "source_key": key}
4965

5066

67+
def _get_oidc_detector_name(debug=False):
68+
"""Get the name of the OIDC detector that would be used."""
69+
try:
70+
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
71+
72+
detector = detect_environment(debug=debug)
73+
if detector:
74+
return detector.name
75+
except Exception: # pylint: disable=broad-exception-caught
76+
pass
77+
return None
78+
79+
80+
def _is_oidc_configured():
81+
"""Check if OIDC environment variables are set."""
82+
org = os.environ.get("CLOUDSMITH_ORG", "").strip()
83+
service = os.environ.get("CLOUDSMITH_SERVICE_SLUG", "").strip()
84+
return bool(org and service)
85+
86+
5187
def _get_sso_status(api_host):
5288
"""Return SSO token status from the system keyring."""
5389
enabled = keyring.should_use_keyring()
@@ -120,7 +156,12 @@ def _print_verbose_text(data):
120156
click.echo(f" Source: {ak['source']}")
121157
click.echo(" Note: SSO token is being used instead")
122158
elif active == "api_key":
123-
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
159+
if ak.get("source_key") == "oidc":
160+
click.secho(
161+
"Authentication Method: OIDC Auto-Discovery", fg="cyan", bold=True
162+
)
163+
else:
164+
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
124165
for label, field in [
125166
("Source", "source"),
126167
("Token Slug", "slug"),

cloudsmith_cli/cli/config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ def update_api_key(cls, path, api_key):
247247
cls._set_api_key(path, api_key)
248248

249249

250-
class Options:
250+
class Options: # pylint: disable=too-many-public-methods
251251
"""Options object that holds config for the application."""
252252

253253
def __init__(self, *args, **kwargs):
@@ -277,6 +277,16 @@ def load_creds_file(self, path, profile=None):
277277
config_cls = self.get_creds_reader()
278278
return config_cls.load_config(self, path, profile=profile)
279279

280+
@property
281+
def credential(self):
282+
"""Get the resolved credential result from the provider chain."""
283+
return self._get_option("credential")
284+
285+
@credential.setter
286+
def credential(self, value):
287+
"""Set the resolved credential result."""
288+
self._set_option("credential", value)
289+
280290
@property
281291
def api_config(self):
282292
"""Get value for API config dictionary."""

cloudsmith_cli/cli/decorators.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,7 @@ def _set_boolean(name, invert=False):
336336
chain = CredentialProviderChain()
337337

338338
credential = chain.resolve(credential_context)
339+
opts.credential = credential
339340

340341
if credential_context.keyring_refresh_failed:
341342
click.secho(

cloudsmith_cli/core/credentials/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class CredentialProviderChain:
7979
"""Evaluates credential providers in order, returning the first valid result.
8080
8181
If no providers are given, uses the default chain:
82-
Keyring → CLIFlag → EnvironmentVariable → ConfigFile.
82+
Keyring → CLIFlag → EnvironmentVariable → ConfigFile → OIDC.
8383
"""
8484

8585
def __init__(self, providers: list[CredentialProvider] | None = None):
@@ -91,13 +91,15 @@ def __init__(self, providers: list[CredentialProvider] | None = None):
9191
ConfigFileProvider,
9292
EnvironmentVariableProvider,
9393
KeyringProvider,
94+
OidcProvider,
9495
)
9596

9697
self.providers = [
9798
KeyringProvider(),
9899
CLIFlagProvider(),
99100
EnvironmentVariableProvider(),
100101
ConfigFileProvider(),
102+
OidcProvider(),
101103
]
102104

103105
def resolve(self, context: CredentialContext) -> CredentialResult | None:
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""OIDC support for the Cloudsmith CLI credential chain.
2+
3+
References:
4+
https://help.cloudsmith.io/docs/openid-connect
5+
https://cloudsmith.com/blog/securely-connect-cloudsmith-to-your-cicd-using-oidc-authentication
6+
"""
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""OIDC token cache.
2+
3+
Caches Cloudsmith API tokens obtained via OIDC exchange to avoid unnecessary
4+
re-exchanges. Uses system keyring when available (respecting CLOUDSMITH_NO_KEYRING),
5+
with automatic fallback to filesystem storage when keyring is unavailable.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import hashlib
11+
import json
12+
import logging
13+
import os
14+
import time
15+
16+
logger = logging.getLogger(__name__)
17+
18+
EXPIRY_MARGIN_SECONDS = 60
19+
20+
_CACHE_DIR_NAME = "oidc_token_cache"
21+
22+
23+
def _get_cache_dir() -> str:
24+
"""Return the cache directory path, creating it if needed."""
25+
from ....cli.config import get_default_config_path
26+
27+
base = get_default_config_path()
28+
cache_dir = os.path.join(base, _CACHE_DIR_NAME)
29+
if not os.path.isdir(cache_dir):
30+
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
31+
return cache_dir
32+
33+
34+
def _cache_key(api_host: str, org: str, service_slug: str) -> str:
35+
"""Compute a deterministic cache filename from the exchange parameters."""
36+
raw = f"{api_host}|{org}|{service_slug}"
37+
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
38+
return f"oidc_{digest}.json"
39+
40+
41+
def _decode_jwt_exp(token: str) -> float | None:
42+
"""Decode the exp claim from a JWT without verification."""
43+
try:
44+
import jwt
45+
46+
payload = jwt.decode(
47+
token,
48+
options={"verify_signature": False},
49+
algorithms=["RS256", "ES256", "HS256"],
50+
)
51+
exp = payload.get("exp")
52+
if exp is not None:
53+
return float(exp)
54+
except Exception: # pylint: disable=broad-exception-caught
55+
logger.debug("Failed to decode JWT expiry", exc_info=True)
56+
return None
57+
58+
59+
def get_cached_token(api_host: str, org: str, service_slug: str) -> str | None:
60+
"""Return a cached token if it exists and is not expired."""
61+
token = _get_from_keyring(api_host, org, service_slug)
62+
if token:
63+
return token
64+
return _get_from_disk(api_host, org, service_slug)
65+
66+
67+
def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None:
68+
"""Try to get token from keyring."""
69+
try:
70+
from ...keyring import get_oidc_token
71+
72+
token_data = get_oidc_token(api_host, org, service_slug)
73+
if not token_data:
74+
return None
75+
76+
data = json.loads(token_data)
77+
token = data.get("token")
78+
expires_at = data.get("expires_at")
79+
80+
if not token:
81+
return None
82+
83+
if expires_at is not None:
84+
remaining = expires_at - time.time()
85+
if remaining < EXPIRY_MARGIN_SECONDS:
86+
logger.debug(
87+
"Keyring OIDC token expired or expiring soon "
88+
"(%.0fs remaining, margin=%ds)",
89+
remaining,
90+
EXPIRY_MARGIN_SECONDS,
91+
)
92+
from ...keyring import delete_oidc_token
93+
94+
delete_oidc_token(api_host, org, service_slug)
95+
return None
96+
logger.debug("Using keyring OIDC token (expires in %.0fs)", remaining)
97+
else:
98+
logger.debug("Using keyring OIDC token (no expiry information)")
99+
100+
return token
101+
102+
except Exception: # pylint: disable=broad-exception-caught
103+
logger.debug("Failed to read OIDC token from keyring", exc_info=True)
104+
return None
105+
106+
107+
def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None:
108+
"""Try to get token from disk cache."""
109+
cache_dir = _get_cache_dir()
110+
cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug))
111+
112+
if not os.path.isfile(cache_file):
113+
return None
114+
115+
try:
116+
with open(cache_file) as f:
117+
data = json.load(f)
118+
119+
token = data.get("token")
120+
expires_at = data.get("expires_at")
121+
122+
if not token:
123+
return None
124+
125+
if expires_at is not None:
126+
remaining = expires_at - time.time()
127+
if remaining < EXPIRY_MARGIN_SECONDS:
128+
logger.debug(
129+
"Disk cached OIDC token expired or expiring soon "
130+
"(%.0fs remaining, margin=%ds)",
131+
remaining,
132+
EXPIRY_MARGIN_SECONDS,
133+
)
134+
_remove_cache_file(cache_file)
135+
return None
136+
logger.debug("Using disk cached OIDC token (expires in %.0fs)", remaining)
137+
else:
138+
logger.debug("Using disk cached OIDC token (no expiry information)")
139+
140+
return token
141+
142+
except (json.JSONDecodeError, OSError, KeyError):
143+
logger.debug("Failed to read OIDC token from disk cache", exc_info=True)
144+
_remove_cache_file(cache_file)
145+
return None
146+
147+
148+
def store_cached_token(api_host: str, org: str, service_slug: str, token: str) -> None:
149+
"""Cache a token in keyring (if available) or filesystem."""
150+
expires_at = _decode_jwt_exp(token)
151+
152+
data = {
153+
"token": token,
154+
"expires_at": expires_at,
155+
"api_host": api_host,
156+
"org": org,
157+
"service_slug": service_slug,
158+
"cached_at": time.time(),
159+
}
160+
161+
if _store_in_keyring(api_host, org, service_slug, data):
162+
return
163+
164+
_store_on_disk(api_host, org, service_slug, data)
165+
166+
167+
def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> bool:
168+
"""Try to store token in keyring."""
169+
try:
170+
from ...keyring import store_oidc_token
171+
172+
token_data = json.dumps(data)
173+
success = store_oidc_token(api_host, org, service_slug, token_data)
174+
if success:
175+
logger.debug(
176+
"Stored OIDC token in keyring (expires_at=%s)", data.get("expires_at")
177+
)
178+
return success
179+
except Exception: # pylint: disable=broad-exception-caught
180+
logger.debug("Failed to store OIDC token in keyring", exc_info=True)
181+
return False
182+
183+
184+
def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None:
185+
"""Store token on disk."""
186+
cache_dir = _get_cache_dir()
187+
cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug))
188+
189+
try:
190+
fd = os.open(cache_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
191+
with os.fdopen(fd, "w") as f:
192+
json.dump(data, f)
193+
logger.debug(
194+
"Stored OIDC token on disk (expires_at=%s)", data.get("expires_at")
195+
)
196+
except OSError:
197+
logger.debug("Failed to write OIDC token to disk cache", exc_info=True)
198+
199+
200+
def invalidate_cached_token(api_host: str, org: str, service_slug: str) -> None:
201+
"""Remove a cached token from both keyring and disk."""
202+
try:
203+
from ...keyring import delete_oidc_token
204+
205+
delete_oidc_token(api_host, org, service_slug)
206+
except Exception: # pylint: disable=broad-exception-caught
207+
logger.debug("Failed to delete OIDC token from keyring", exc_info=True)
208+
209+
cache_dir = _get_cache_dir()
210+
cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug))
211+
_remove_cache_file(cache_file)
212+
213+
214+
def _remove_cache_file(path: str) -> None:
215+
"""Safely remove a cache file."""
216+
try:
217+
os.unlink(path)
218+
except (OSError, TypeError):
219+
pass

0 commit comments

Comments
 (0)