Skip to content

Commit 97c2c36

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 e44a907 commit 97c2c36

File tree

17 files changed

+685
-77
lines changed

17 files changed

+685
-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: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ...core import keyring
88
from ...core.api.exceptions import ApiException
99
from ...core.api.user import get_token_metadata, get_user_brief
10+
from ...core.config_resolver import ConfigResolver
1011
from .. import decorators, utils
1112
from ..config import CredentialsReader
1213
from ..exceptions import handle_api_exceptions
@@ -29,6 +30,14 @@ def _get_api_key_source(opts):
2930
Checks in priority order matching actual resolution:
3031
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini.
3132
"""
33+
credential = getattr(opts, "credential", None)
34+
if credential:
35+
return {
36+
"configured": True,
37+
"source": credential.source_detail or credential.source_name,
38+
"source_key": credential.source_name,
39+
}
40+
3241
if not opts.api_key:
3342
return {"configured": False, "source": None, "source_key": None}
3443

@@ -42,12 +51,40 @@ def _get_api_key_source(opts):
4251
source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var"
4352
elif creds := CredentialsReader.find_existing_files():
4453
source, key = f"credentials.ini ({creds[0]})", "credentials_file"
54+
elif _is_oidc_configured():
55+
config = ConfigResolver().resolve()
56+
detector_name = _get_oidc_detector_name(debug=opts.debug)
57+
if detector_name:
58+
source = f"OIDC auto-discovery: {detector_name} (org: {config.oidc_org})"
59+
else:
60+
source = f"OIDC auto-discovery (org: {config.oidc_org})"
61+
key = "oidc"
4562
else:
4663
source, key = "CLI --api-key flag", "cli_flag"
4764

4865
return {"configured": True, "source": source, "source_key": key}
4966

5067

68+
def _get_oidc_detector_name(debug=False):
69+
"""Get the name of the OIDC detector that would be used."""
70+
try:
71+
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
72+
73+
config = ConfigResolver().resolve()
74+
detector = detect_environment(config=config, debug=debug)
75+
if detector:
76+
return detector.name
77+
except Exception: # pylint: disable=broad-exception-caught
78+
pass
79+
return None
80+
81+
82+
def _is_oidc_configured():
83+
"""Check if OIDC org and service slug are configured."""
84+
config = ConfigResolver().resolve()
85+
return bool(config.oidc_org and config.oidc_service_slug)
86+
87+
5188
def _get_sso_status(api_host):
5289
"""Return SSO token status from the system keyring."""
5390
enabled = keyring.should_use_keyring()
@@ -120,7 +157,12 @@ def _print_verbose_text(data):
120157
click.echo(f" Source: {ak['source']}")
121158
click.echo(" Note: SSO token is being used instead")
122159
elif active == "api_key":
123-
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
160+
if ak.get("source_key") == "oidc":
161+
click.secho(
162+
"Authentication Method: OIDC Auto-Discovery", fg="cyan", bold=True
163+
)
164+
else:
165+
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
124166
for label, field in [
125167
("Source", "source"),
126168
("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
@@ -337,6 +337,7 @@ def _set_boolean(name, invert=False):
337337
chain = CredentialProviderChain()
338338

339339
credential = chain.resolve(credential_context)
340+
opts.credential = credential
340341

341342
if credential_context.keyring_refresh_failed:
342343
click.secho(

cloudsmith_cli/core/config_resolver.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ class ClientConfig: # pylint: disable=too-many-instance-attributes
183183

184184
# Feature flags
185185
no_keyring: bool = False
186+
oidc_discovery_disabled: bool = False
187+
188+
# OIDC-specific
189+
oidc_org: str | None = None
190+
oidc_service_slug: str | None = None
191+
oidc_audience: str = "cloudsmith"
186192

187193
def create_session(self, api_key: str | None = None, retry=_SENTINEL):
188194
"""Create an HTTP session with this config applied.
@@ -246,6 +252,10 @@ def resolve(self, **overrides: Any) -> ClientConfig:
246252
headers=self._resolve_headers(overrides, profile),
247253
debug=bool(overrides.get("debug", False)),
248254
no_keyring=self._resolve_no_keyring(overrides),
255+
oidc_discovery_disabled=self._resolve_oidc_discovery_disabled(overrides),
256+
oidc_org=self._resolve_oidc_org(overrides),
257+
oidc_service_slug=self._resolve_oidc_service_slug(overrides),
258+
oidc_audience=self._resolve_oidc_audience(overrides),
249259
)
250260

251261
# -- individual resolvers ------------------------------------------------
@@ -328,3 +338,45 @@ def _resolve_no_keyring(overrides: dict) -> bool:
328338
],
329339
default=False,
330340
).resolve()
341+
342+
@staticmethod
343+
def _resolve_oidc_discovery_disabled(overrides: dict) -> bool:
344+
override = overrides.get("oidc_discovery_disabled")
345+
if override is not None:
346+
return bool(override)
347+
return ChainProvider(
348+
[
349+
EnvironmentProvider(
350+
"CLOUDSMITH_OIDC_DISCOVERY_DISABLED", cast=_cast_bool_truthy
351+
),
352+
],
353+
default=False,
354+
).resolve()
355+
356+
@staticmethod
357+
def _resolve_oidc_org(overrides: dict) -> str | None:
358+
return ChainProvider(
359+
[
360+
InstanceProvider(overrides.get("oidc_org")),
361+
EnvironmentProvider("CLOUDSMITH_ORG"),
362+
],
363+
).resolve()
364+
365+
@staticmethod
366+
def _resolve_oidc_service_slug(overrides: dict) -> str | None:
367+
return ChainProvider(
368+
[
369+
InstanceProvider(overrides.get("oidc_service_slug")),
370+
EnvironmentProvider("CLOUDSMITH_SERVICE_SLUG"),
371+
],
372+
).resolve()
373+
374+
@staticmethod
375+
def _resolve_oidc_audience(overrides: dict) -> str:
376+
return ChainProvider(
377+
[
378+
InstanceProvider(overrides.get("oidc_audience")),
379+
EnvironmentProvider("CLOUDSMITH_OIDC_AUDIENCE"),
380+
],
381+
default="cloudsmith",
382+
).resolve()

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+
"""

0 commit comments

Comments
 (0)