Skip to content

Commit f9422d7

Browse files
cloudsmith-iduffyCopilotCopilot
authored
feat: add OIDC credential auto-discovery (#276)
* feat: add credential provider chain concept * fix: review feedback * 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> * refactor: remove CI/CD environment references from OIDC code Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: review feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 023b333 commit f9422d7

19 files changed

Lines changed: 761 additions & 9 deletions

File tree

.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
@@ -141,6 +141,30 @@ Or you can get the latest pre-release version from Cloudsmith:
141141
pip install --upgrade cloudsmith-cli --extra-index-url=https://dl.cloudsmith.io/public/cloudsmith/cli/python/index/
142142
```
143143

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

146170
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
metadata_failure_mode = ConfigParam(name="metadata_failure_mode", type=str)
7073

7174
@matches_section("profile:*")
@@ -444,6 +447,48 @@ def mcp_allowed_tool_groups(self, value):
444447

445448
self._set_option("mcp_allowed_tool_groups", tool_groups)
446449

450+
@property
451+
def oidc_audience(self):
452+
"""Get value for OIDC audience."""
453+
return self._get_option("oidc_audience")
454+
455+
@oidc_audience.setter
456+
def oidc_audience(self, value):
457+
"""Set value for OIDC audience."""
458+
self._set_option("oidc_audience", value)
459+
460+
@property
461+
def oidc_org(self):
462+
"""Get value for OIDC organisation slug."""
463+
return self._get_option("oidc_org")
464+
465+
@oidc_org.setter
466+
def oidc_org(self, value):
467+
"""Set value for OIDC organisation slug."""
468+
self._set_option("oidc_org", value)
469+
470+
@property
471+
def oidc_service_slug(self):
472+
"""Get value for OIDC service slug."""
473+
return self._get_option("oidc_service_slug")
474+
475+
@oidc_service_slug.setter
476+
def oidc_service_slug(self, value):
477+
"""Set value for OIDC service slug."""
478+
self._set_option("oidc_service_slug", value)
479+
480+
@property
481+
def oidc_discovery_disabled(self):
482+
"""Get value for OIDC discovery disabled flag."""
483+
return self._get_option("oidc_discovery_disabled", default=False)
484+
485+
@oidc_discovery_disabled.setter
486+
def oidc_discovery_disabled(self, value):
487+
"""Set value for OIDC discovery disabled flag."""
488+
self._set_option(
489+
"oidc_discovery_disabled", bool(value) if value is not None else False
490+
)
491+
447492
@property
448493
def metadata_failure_mode(self):
449494
"""Get value for push-time metadata failure mode."""

cloudsmith_cli/cli/decorators.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,12 +310,48 @@ def wrapper(ctx, *args, **kwargs):
310310
def resolve_credentials(f):
311311
"""Resolve credentials via the provider chain. Depends on initialise_session."""
312312

313+
@click.option(
314+
"--oidc-audience",
315+
envvar="CLOUDSMITH_OIDC_AUDIENCE",
316+
help="The OIDC audience for token requests.",
317+
)
318+
@click.option(
319+
"--oidc-org",
320+
envvar="CLOUDSMITH_ORG",
321+
help="The Cloudsmith organisation slug for OIDC token exchange.",
322+
)
323+
@click.option(
324+
"--oidc-service-slug",
325+
envvar="CLOUDSMITH_SERVICE_SLUG",
326+
help="The Cloudsmith service slug for OIDC token exchange.",
327+
)
328+
@click.option(
329+
"--oidc-discovery-disabled",
330+
default=None,
331+
is_flag=True,
332+
envvar="CLOUDSMITH_OIDC_DISCOVERY_DISABLED",
333+
help="Disable OIDC auto-discovery.",
334+
)
313335
@click.pass_context
314336
@functools.wraps(f)
315337
def wrapper(ctx, *args, **kwargs):
316338
# pylint: disable=missing-docstring
317339
opts = config.get_or_create_options(ctx)
318340

341+
oidc_audience = kwargs.pop("oidc_audience")
342+
oidc_org = kwargs.pop("oidc_org")
343+
oidc_service_slug = kwargs.pop("oidc_service_slug")
344+
oidc_discovery_disabled = _pop_boolean_flag(kwargs, "oidc_discovery_disabled")
345+
346+
if oidc_audience:
347+
opts.oidc_audience = oidc_audience
348+
if oidc_org:
349+
opts.oidc_org = oidc_org
350+
if oidc_service_slug:
351+
opts.oidc_service_slug = oidc_service_slug
352+
if oidc_discovery_disabled:
353+
opts.oidc_discovery_disabled = oidc_discovery_disabled
354+
319355
context = CredentialContext(
320356
session=opts.session,
321357
api_key_from_flag=opts.api_key_from_flag,
@@ -325,6 +361,10 @@ def wrapper(ctx, *args, **kwargs):
325361
creds_file_path=ctx.meta.get("creds_file"),
326362
profile=ctx.meta.get("profile"),
327363
debug=opts.debug,
364+
oidc_audience=opts.oidc_audience,
365+
oidc_org=opts.oidc_org,
366+
oidc_service_slug=opts.oidc_service_slug,
367+
oidc_discovery_disabled=opts.oidc_discovery_disabled,
328368
)
329369

330370
chain = CredentialProviderChain()

cloudsmith_cli/core/credentials/chain.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
CredentialsFileProvider,
1616
EnvVarProvider,
1717
KeyringProvider,
18+
OidcProvider,
1819
)
1920

2021
logger = logging.getLogger(__name__)
@@ -24,7 +25,7 @@ class CredentialProviderChain:
2425
"""Evaluates credential providers in order, returning the first valid result.
2526
2627
If no providers are given, uses the default chain:
27-
CLIFlag → EnvVar → CredentialsFile → Keyring.
28+
CLIFlag → EnvVar → CredentialsFile → Keyring → OIDC.
2829
"""
2930

3031
def __init__(self, providers: list[CredentialProvider] | None = None):
@@ -36,6 +37,7 @@ def __init__(self, providers: list[CredentialProvider] | None = None):
3637
EnvVarProvider(),
3738
CredentialsFileProvider(),
3839
KeyringProvider(),
40+
OidcProvider(),
3941
]
4042

4143
def resolve(self, context: CredentialContext) -> CredentialResult | None:

cloudsmith_cli/core/credentials/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ class CredentialContext:
2626
profile: str | None = None
2727
debug: bool = False
2828
keyring_refresh_failed: bool = False
29+
oidc_audience: str | None = None
30+
oidc_org: str | None = None
31+
oidc_service_slug: str | None = None
32+
oidc_discovery_disabled: bool = False
2933

3034

3135
@dataclass
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)