Skip to content

Commit 0070269

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 050c1b6 commit 0070269

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
@@ -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/config_resolver.py

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

167167
# Feature flags
168168
no_keyring: bool = False
169+
oidc_discovery_disabled: bool = False
170+
171+
# OIDC-specific
172+
oidc_org: str | None = None
173+
oidc_service_slug: str | None = None
174+
oidc_audience: str = "cloudsmith"
169175

170176
def create_session(self, api_key: str | None = None, retry=_SENTINEL):
171177
"""Create an HTTP session with this config applied.
@@ -227,6 +233,10 @@ def resolve(self, **overrides: Any) -> ClientConfig:
227233
headers=self._resolve_headers(overrides),
228234
debug=bool(overrides.get("debug", False)),
229235
no_keyring=self._resolve_no_keyring(overrides),
236+
oidc_discovery_disabled=self._resolve_oidc_discovery_disabled(overrides),
237+
oidc_org=self._resolve_oidc_org(overrides),
238+
oidc_service_slug=self._resolve_oidc_service_slug(overrides),
239+
oidc_audience=self._resolve_oidc_audience(overrides),
230240
)
231241

232242
# -- individual resolvers ------------------------------------------------
@@ -298,3 +308,45 @@ def _resolve_no_keyring(overrides: dict) -> bool:
298308
],
299309
default=False,
300310
).resolve()
311+
312+
@staticmethod
313+
def _resolve_oidc_discovery_disabled(overrides: dict) -> bool:
314+
override = overrides.get("oidc_discovery_disabled")
315+
if override is not None:
316+
return bool(override)
317+
return ChainProvider(
318+
[
319+
EnvironmentProvider(
320+
"CLOUDSMITH_OIDC_DISCOVERY_DISABLED", cast=_cast_bool_truthy
321+
),
322+
],
323+
default=False,
324+
).resolve()
325+
326+
@staticmethod
327+
def _resolve_oidc_org(overrides: dict) -> str | None:
328+
return ChainProvider(
329+
[
330+
InstanceProvider(overrides.get("oidc_org")),
331+
EnvironmentProvider("CLOUDSMITH_ORG"),
332+
],
333+
).resolve()
334+
335+
@staticmethod
336+
def _resolve_oidc_service_slug(overrides: dict) -> str | None:
337+
return ChainProvider(
338+
[
339+
InstanceProvider(overrides.get("oidc_service_slug")),
340+
EnvironmentProvider("CLOUDSMITH_SERVICE_SLUG"),
341+
],
342+
).resolve()
343+
344+
@staticmethod
345+
def _resolve_oidc_audience(overrides: dict) -> str:
346+
return ChainProvider(
347+
[
348+
InstanceProvider(overrides.get("oidc_audience")),
349+
EnvironmentProvider("CLOUDSMITH_OIDC_AUDIENCE"),
350+
],
351+
default="cloudsmith",
352+
).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)