Skip to content

Commit 595f343

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 aab4997 commit 595f343

File tree

16 files changed

+796
-4
lines changed

16 files changed

+796
-4
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
@@ -330,6 +330,7 @@ def _set_boolean(name, invert=False):
330330
chain = CredentialProviderChain()
331331

332332
credential = chain.resolve(credential_context)
333+
opts.credential = credential
333334

334335
if credential_context.keyring_refresh_failed:
335336
click.secho(

cloudsmith_cli/core/credentials/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class CredentialProviderChain:
5656
"""Evaluates credential providers in order, returning the first valid result.
5757
5858
If no providers are given, uses the default chain:
59-
Keyring → CLIFlag → EnvironmentVariable → ConfigFile.
59+
Keyring → CLIFlag → EnvironmentVariable → ConfigFile → OIDC.
6060
"""
6161

6262
def __init__(self, providers: list[CredentialProvider] | None = None):
@@ -68,13 +68,15 @@ def __init__(self, providers: list[CredentialProvider] | None = None):
6868
ConfigFileProvider,
6969
EnvironmentVariableProvider,
7070
KeyringProvider,
71+
OidcProvider,
7172
)
7273

7374
self.providers = [
7475
KeyringProvider(),
7576
CLIFlagProvider(),
7677
EnvironmentVariableProvider(),
7778
ConfigFileProvider(),
79+
OidcProvider(),
7880
]
7981

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

0 commit comments

Comments
 (0)