Skip to content

Commit 1531232

Browse files
feat: add OIDC auto-discovery to credential provider chain
Add AWS OIDC support as the final provider in the credential chain (Keyring → CLIFlag → OIDC). When CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG are set, the CLI auto-detects the CI/CD environment, retrieves a vendor OIDC JWT via STS GetWebIdentityToken, and exchanges it for a short-lived Cloudsmith API token. - AWS detector with boto3 session reuse and default audience ('cloudsmith') - Token cache (keyring with filesystem fallback) checked before detection - OIDC token exchange against POST /openid/{org}/ - CLI options: --oidc-org, --oidc-service-slug, --oidc-audience, --oidc-discovery-disabled - Optional dependency: pip install cloudsmith-cli[aws] - Warning-level logs on OIDC failures for CI/CD debuggability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1cef871 commit 1531232

File tree

18 files changed

+758
-10
lines changed

18 files changed

+758
-10
lines changed

.pylintrc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,9 @@ disable=raw-checker-failed,
455455
used-before-assignment,
456456
unneeded-not,
457457
duplicate-code,
458-
cyclic-import
458+
cyclic-import,
459+
too-many-public-methods,
460+
too-many-instance-attributes
459461

460462

461463
# Enable the message, report, category or checker with the given id(s). You can

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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,12 @@ def _print_verbose_text(data):
108108
click.echo(f" Source: {ak['source']}")
109109
click.echo(" Note: SSO token is being used instead")
110110
elif active == "api_key":
111-
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
111+
if ak.get("source_key") == "oidc":
112+
click.secho(
113+
"Authentication Method: OIDC Auto-Discovery", fg="cyan", bold=True
114+
)
115+
else:
116+
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
112117
for label, field in [
113118
("Source", "source"),
114119
("Token Slug", "slug"),

cloudsmith_cli/cli/config.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ class Default(SectionSchema):
6666
api_user_agent = ConfigParam(name="api_user_agent", type=str)
6767
mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str)
6868
mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str)
69+
oidc_audience = ConfigParam(name="oidc_audience", type=str)
70+
oidc_org = ConfigParam(name="oidc_org", type=str)
71+
oidc_service_slug = ConfigParam(name="oidc_service_slug", type=str)
6972

7073
@matches_section("profile:*")
7174
class Profile(Default):
@@ -416,6 +419,48 @@ def mcp_allowed_tool_groups(self, value):
416419

417420
self._set_option("mcp_allowed_tool_groups", tool_groups)
418421

422+
@property
423+
def oidc_audience(self):
424+
"""Get value for OIDC audience."""
425+
return self._get_option("oidc_audience")
426+
427+
@oidc_audience.setter
428+
def oidc_audience(self, value):
429+
"""Set value for OIDC audience."""
430+
self._set_option("oidc_audience", value)
431+
432+
@property
433+
def oidc_org(self):
434+
"""Get value for OIDC organisation slug."""
435+
return self._get_option("oidc_org")
436+
437+
@oidc_org.setter
438+
def oidc_org(self, value):
439+
"""Set value for OIDC organisation slug."""
440+
self._set_option("oidc_org", value)
441+
442+
@property
443+
def oidc_service_slug(self):
444+
"""Get value for OIDC service slug."""
445+
return self._get_option("oidc_service_slug")
446+
447+
@oidc_service_slug.setter
448+
def oidc_service_slug(self, value):
449+
"""Set value for OIDC service slug."""
450+
self._set_option("oidc_service_slug", value)
451+
452+
@property
453+
def oidc_discovery_disabled(self):
454+
"""Get value for OIDC discovery disabled flag."""
455+
return self._get_option("oidc_discovery_disabled", default=False)
456+
457+
@oidc_discovery_disabled.setter
458+
def oidc_discovery_disabled(self, value):
459+
"""Set value for OIDC discovery disabled flag."""
460+
self._set_option(
461+
"oidc_discovery_disabled", bool(value) if value is not None else False
462+
)
463+
419464
@property
420465
def output(self):
421466
"""Get value for output format."""

cloudsmith_cli/cli/decorators.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,19 +293,59 @@ def wrapper(ctx, *args, **kwargs):
293293
def resolve_credentials(f):
294294
"""Resolve credentials via the provider chain. Depends on initialise_session."""
295295

296+
@click.option(
297+
"--oidc-audience",
298+
envvar="CLOUDSMITH_OIDC_AUDIENCE",
299+
help="The OIDC audience for token requests.",
300+
)
301+
@click.option(
302+
"--oidc-org",
303+
envvar="CLOUDSMITH_ORG",
304+
help="The Cloudsmith organisation slug for OIDC token exchange.",
305+
)
306+
@click.option(
307+
"--oidc-service-slug",
308+
envvar="CLOUDSMITH_SERVICE_SLUG",
309+
help="The Cloudsmith service slug for OIDC token exchange.",
310+
)
311+
@click.option(
312+
"--oidc-discovery-disabled",
313+
default=None,
314+
is_flag=True,
315+
envvar="CLOUDSMITH_OIDC_DISCOVERY_DISABLED",
316+
help="Disable OIDC auto-discovery in CI/CD environments.",
317+
)
296318
@click.pass_context
297319
@functools.wraps(f)
298320
def wrapper(ctx, *args, **kwargs):
299321
# pylint: disable=missing-docstring
300322
opts = config.get_or_create_options(ctx)
301323

324+
oidc_audience = kwargs.pop("oidc_audience")
325+
oidc_org = kwargs.pop("oidc_org")
326+
oidc_service_slug = kwargs.pop("oidc_service_slug")
327+
oidc_discovery_disabled = _pop_boolean_flag(kwargs, "oidc_discovery_disabled")
328+
329+
if oidc_audience:
330+
opts.oidc_audience = oidc_audience
331+
if oidc_org:
332+
opts.oidc_org = oidc_org
333+
if oidc_service_slug:
334+
opts.oidc_service_slug = oidc_service_slug
335+
if oidc_discovery_disabled:
336+
opts.oidc_discovery_disabled = oidc_discovery_disabled
337+
302338
context = CredentialContext(
303339
session=opts.session,
304340
api_key=opts.api_key,
305341
api_host=opts.api_host or "https://api.cloudsmith.io",
306342
creds_file_path=ctx.meta.get("creds_file"),
307343
profile=ctx.meta.get("profile"),
308344
debug=opts.debug,
345+
oidc_audience=opts.oidc_audience,
346+
oidc_org=opts.oidc_org,
347+
oidc_service_slug=opts.oidc_service_slug,
348+
oidc_discovery_disabled=opts.oidc_discovery_disabled,
309349
)
310350

311351
chain = CredentialProviderChain()

cloudsmith_cli/core/credentials/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class CredentialContext:
3131
profile: str | None = None
3232
debug: bool = False
3333
keyring_refresh_failed: bool = False
34+
oidc_audience: str | None = None
35+
oidc_org: str | None = None
36+
oidc_service_slug: str | None = None
37+
oidc_discovery_disabled: bool = False
3438

3539

3640
@dataclass
@@ -57,18 +61,19 @@ class CredentialProviderChain:
5761
"""Evaluates credential providers in order, returning the first valid result.
5862
5963
If no providers are given, uses the default chain:
60-
Keyring → CLIFlag.
64+
Keyring → CLIFlag → OIDC.
6165
"""
6266

6367
def __init__(self, providers: list[CredentialProvider] | None = None):
6468
if providers is not None:
6569
self.providers = providers
6670
else:
67-
from .providers import CLIFlagProvider, KeyringProvider
71+
from .providers import CLIFlagProvider, KeyringProvider, OidcProvider
6872

6973
self.providers = [
7074
KeyringProvider(),
7175
CLIFlagProvider(),
76+
OidcProvider(),
7277
]
7378

7479
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)