Skip to content

Commit df70f37

Browse files
feat: Add OIDC authentication with automatic CI/CD platform detection
Implements AWS-style credential provider chain with automatic OIDC token discovery for 7 major CI/CD platforms (GitHub Actions, GitLab CI, CircleCI, Azure DevOps, Bitbucket Pipelines, AWS, and Jenkins/Generic). Type of Change: - New feature Key Features: - Credential provider chain with priority: CLI flag > env var > config file > keyring > OIDC - Auto-detects CI/CD environment and exchanges vendor JWT for Cloudsmith token - Keyring-first token caching with disk fallback - New `cloudsmith print-token` command to export tokens for curl, docker, etc. - Platform-specific OIDC detection shown in `whoami --verbose` output Platform Support: - GitHub Actions: Fetches token via ACTIONS_ID_TOKEN_REQUEST_URL - GitLab CI: Reads CI_JOB_JWT_V2 or CI_JOB_JWT - CircleCI: Reads CIRCLE_OIDC_TOKEN_V2 or CIRCLE_OIDC_TOKEN - Azure DevOps: Fetches token via SYSTEM_OIDCREQUESTURI - Bitbucket Pipelines: Reads BITBUCKET_STEP_OIDC_TOKEN - AWS (ECS/EKS/EC2/Lambda): Uses boto3 credential chain + STS GetWebIdentityToken - Generic/Jenkins: Reads CLOUDSMITH_OIDC_TOKEN (requires credentials-binding plugin) Example Usage (AWS): ```bash $ env | grep CLOUDSMITH_ CLOUDSMITH_ORG=iduffy-demo CLOUDSMITH_SERVICE_SLUG=default-v9ty $ stat ~/.cloudsmith/config.ini stat: cannot stat '/Users/iduffy/.cloudsmith/config.ini': No such file or directory $ aws sts get-caller-identity { "UserId": "AROA47EXAMPLE:ian@ianduffy.ie", "Account": "893EXAMPLE", "Arn": "arn:aws:sts::893EXAMPLE:assumed-role/AWSReservedSSO_NOPE_..." } $ cloudsmith whoami --verbose Retrieving your authentication status from the API ... OK User: default (slug: default-v9ty) Authentication Method: OIDC Auto-Discovery Source: OIDC auto-discovery: AWS (org: iduffy-demo) Token Slug: sACcPOv3Iro8 Created: 2025-06-07T19:43:47.840466Z 💡 Export this token: cloudsmith print-token $ cloudsmith list repos $ cloudsmith print-token eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... $ curl -H "X-Api-Key: $(cloudsmith print-token)" https://api.cloudsmith.io/v1/user/self/ $ docker login docker.cloudsmith.io -u token -p $(cloudsmith print-token) ``` Configuration: Requires two environment variables: - CLOUDSMITH_ORG: Organization slug - CLOUDSMITH_SERVICE_SLUG: Service account slug Optional: - CLOUDSMITH_OIDC_AUDIENCE: Override default audience (default: "cloudsmith") - CLOUDSMITH_NO_KEYRING=1: Skip keyring, use disk cache only Installation: - Base install: pip install cloudsmith-cli - With AWS support: pip install cloudsmith-cli[aws] - All features: pip install cloudsmith-cli[all] Security: - Tokens cached with 60-second expiry margin for auto-refresh - Cache files ideally use keyring but if they go to disk created with 0o600 permissions - Exponential backoff with jitter for retry resilience Backwards Compatibility: - No breaking changes to existing authentication methods - OIDC is opt-in via environment variables - Existing API key/SSO authentication unaffected
1 parent c7eabc7 commit df70f37

23 files changed

Lines changed: 2553 additions & 7 deletions

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,30 @@ Or you can get the latest pre-release version from Cloudsmith:
134134
pip install --upgrade cloudsmith-cli --extra-index-url=https://dl.cloudsmith.io/public/cloudsmith/cli/python/index/
135135
```
136136

137+
### Optional Dependencies
138+
139+
The CLI supports optional extras for additional functionality:
140+
141+
#### AWS OIDC Support
142+
143+
For AWS environments (ECS, EKS, EC2), install with `aws` extra to enable automatic credential discovery:
144+
145+
```
146+
pip install cloudsmith-cli[aws]
147+
```
148+
149+
This installs `boto3[crt]` for AWS credential chain support, STS token generation, and AWS SSO compatibility.
150+
151+
#### All Optional Features
152+
153+
To install all optional dependencies:
154+
155+
```
156+
pip install cloudsmith-cli[all]
157+
```
158+
159+
**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors. All other CI/CD platforms (GitHub Actions, GitLab CI, CircleCI, Azure DevOps, Bitbucket Pipelines, Jenkins) work without any extras.
160+
137161
## Configuration
138162

139163
There are two configuration files used by the CLI:

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
metrics,
1818
move,
1919
policy,
20+
print_token,
2021
push,
2122
quarantine,
2223
quota,

cloudsmith_cli/cli/commands/login.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name
9494
"Your API key/token is: %(token)s"
9595
% {"token": click.style(api_key, fg="magenta")}
9696
)
97+
click.echo()
98+
click.echo(
99+
"💡 Tip: Use "
100+
+ click.style("cloudsmith print-token", fg="cyan")
101+
+ " to retrieve this token later"
102+
)
97103

98104
create, has_errors = create_config_files(ctx, opts, api_key=api_key)
99105
new_config_messaging(has_errors, opts, create, api_key=api_key)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""CLI/Commands - Print the active authentication token."""
2+
3+
import click
4+
5+
from .. import decorators, utils
6+
from .main import main
7+
8+
9+
def _extract_token(api_config):
10+
"""Extract the active token from the API configuration.
11+
12+
Returns (token, token_type) where token_type is 'bearer' or 'api_key'.
13+
"""
14+
headers = getattr(api_config, "headers", {}) or {}
15+
auth_header = headers.get("Authorization", "")
16+
17+
if auth_header.startswith("Bearer "):
18+
return auth_header[len("Bearer ") :], "bearer"
19+
20+
api_keys = getattr(api_config, "api_key", {}) or {}
21+
api_key = api_keys.get("X-Api-Key")
22+
if api_key:
23+
return api_key, "api_key"
24+
25+
return None, None
26+
27+
28+
@main.command(name="print-token")
29+
@decorators.common_cli_config_options
30+
@decorators.common_cli_output_options
31+
@decorators.common_api_auth_options
32+
@decorators.initialise_api
33+
@click.pass_context
34+
def token(ctx, opts):
35+
"""Print the active authentication token.
36+
37+
Outputs the token currently used to authenticate with the Cloudsmith API.
38+
This is useful for passing to other tools like curl, docker, pip, etc.
39+
40+
Note: This prints your CURRENT/ACTIVE token. To LOGIN and get a NEW token
41+
interactively, use 'cloudsmith login' (or its alias 'cloudsmith token').
42+
43+
⚠️ WARNING: This command prints sensitive credentials to stdout.
44+
Avoid running this command in logged/recorded terminal sessions.
45+
The token will appear in your shell history if stored in a variable.
46+
47+
For safer usage, pipe directly to another command without storing
48+
in variables.
49+
50+
\b
51+
Examples:
52+
# Use with curl (pipe directly)
53+
cloudsmith print-token | xargs -I{} curl -H "X-Api-Key: {}" https://api.cloudsmith.io/v1/user/self/
54+
55+
# Use with docker login (pipe to stdin)
56+
cloudsmith print-token | docker login docker.cloudsmith.io -u token --password-stdin
57+
58+
# Avoid: storing in shell variable (appears in history)
59+
# export CS_TOKEN=$(cloudsmith print-token) # NOT RECOMMENDED
60+
61+
\b
62+
See also:
63+
cloudsmith login Interactive login to get a NEW token
64+
cloudsmith whoami Show current authentication status
65+
"""
66+
active_token, token_type = _extract_token(opts.api_config)
67+
68+
if not active_token:
69+
click.secho("Error: No authentication token available", fg="red", err=True)
70+
click.echo(err=True)
71+
click.echo("Try one of these commands to authenticate:", err=True)
72+
click.echo(
73+
" "
74+
+ click.style("cloudsmith login", fg="cyan")
75+
+ " # Interactive login",
76+
err=True,
77+
)
78+
click.echo(
79+
" "
80+
+ click.style("cloudsmith authenticate", fg="cyan")
81+
+ " # SAML/SSO login",
82+
err=True,
83+
)
84+
click.echo(
85+
" "
86+
+ click.style("export CLOUDSMITH_API_KEY=...", fg="cyan")
87+
+ " # Set via environment variable",
88+
err=True,
89+
)
90+
click.echo(err=True)
91+
click.echo(
92+
"For OIDC auto-discovery, set CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG",
93+
err=True,
94+
)
95+
ctx.exit(1)
96+
97+
if utils.maybe_print_as_json(
98+
opts,
99+
{"token": active_token, "type": token_type},
100+
):
101+
return
102+
103+
# Print bare token to stdout (stderr used for any messages)
104+
click.echo(active_token)

cloudsmith_cli/cli/commands/whoami.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,27 +27,74 @@ def _get_api_key_source(opts):
2727
"""Determine where the API key was loaded from.
2828
2929
Checks in priority order matching actual resolution:
30-
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini.
30+
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini > OIDC.
3131
"""
32-
if not opts.api_key:
32+
# Check if ANY API key is configured (from any source)
33+
api_key_configured = opts.api_key or (
34+
hasattr(opts, "api_config") and opts.api_config and opts.api_config.api_key
35+
)
36+
37+
if not api_key_configured:
3338
return {"configured": False, "source": None, "source_key": None}
3439

3540
env_key = os.environ.get("CLOUDSMITH_API_KEY")
3641

37-
# If env var is set but differs from the resolved key, CLI flag won
38-
if env_key and opts.api_key != env_key:
39-
source, key = "CLI --api-key flag", "cli_flag"
42+
# If CLI --api-key flag was explicitly passed
43+
if opts.api_key:
44+
# If env var is set but differs from the CLI flag, CLI flag won
45+
if env_key and opts.api_key != env_key:
46+
source, key = "CLI --api-key flag", "cli_flag"
47+
# If env var is set and matches, it's actually from env var
48+
elif env_key and opts.api_key == env_key:
49+
suffix = env_key[-4:]
50+
source, key = (
51+
f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})",
52+
"env_var",
53+
)
54+
# CLI flag was set explicitly (not from env)
55+
else:
56+
source, key = "CLI --api-key flag", "cli_flag"
57+
# No CLI flag, check other sources
4058
elif env_key:
4159
suffix = env_key[-4:]
4260
source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var"
4361
elif creds := CredentialsReader.find_existing_files():
4462
source, key = f"credentials.ini ({creds[0]})", "credentials_file"
63+
elif _is_oidc_configured():
64+
org = os.environ.get("CLOUDSMITH_ORG", "")
65+
detector_name = _get_oidc_detector_name()
66+
if detector_name:
67+
source = f"OIDC auto-discovery: {detector_name} (org: {org})"
68+
else:
69+
source = f"OIDC auto-discovery (org: {org})"
70+
key = "oidc"
4571
else:
46-
source, key = "CLI --api-key flag", "cli_flag"
72+
source, key = "Unknown source", "unknown"
4773

4874
return {"configured": True, "source": source, "source_key": key}
4975

5076

77+
def _get_oidc_detector_name():
78+
"""Get the name of the OIDC detector that would be used."""
79+
try:
80+
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
81+
82+
detector = detect_environment(debug=False)
83+
if detector:
84+
return detector.name
85+
except Exception: # pylint: disable=broad-exception-caught
86+
# Gracefully handle any detection failures - this is for display only
87+
pass
88+
return None
89+
90+
91+
def _is_oidc_configured():
92+
"""Check if OIDC environment variables are set."""
93+
return bool(
94+
os.environ.get("CLOUDSMITH_ORG") and os.environ.get("CLOUDSMITH_SERVICE_SLUG")
95+
)
96+
97+
5198
def _get_sso_status(api_host):
5299
"""Return SSO token status from the system keyring."""
53100
enabled = keyring.should_use_keyring()
@@ -120,14 +167,23 @@ def _print_verbose_text(data):
120167
click.echo(f" Source: {ak['source']}")
121168
click.echo(" Note: SSO token is being used instead")
122169
elif active == "api_key":
123-
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
170+
if ak.get("source_key") == "oidc":
171+
click.secho(
172+
"Authentication Method: OIDC Auto-Discovery", fg="cyan", bold=True
173+
)
174+
else:
175+
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
124176
for label, field in [
125177
("Source", "source"),
126178
("Token Slug", "slug"),
127179
("Created", "created"),
128180
]:
129181
if ak.get(field):
130182
click.echo(f" {label}: {ak[field]}")
183+
click.echo()
184+
click.echo(
185+
"💡 Export this token: " + click.style("cloudsmith print-token", fg="cyan")
186+
)
131187
else:
132188
click.secho("Authentication Method: None (anonymous)", fg="yellow", bold=True)
133189

cloudsmith_cli/core/api/init.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Cloudsmith API - Initialisation."""
22

33
import base64
4+
import logging
45
from typing import Type, TypeVar
56

67
import click
@@ -11,6 +12,37 @@
1112
from ..rest import RestClient
1213
from .exceptions import ApiException
1314

15+
logger = logging.getLogger(__name__)
16+
17+
18+
def _try_oidc_credential(config):
19+
"""Attempt OIDC auto-discovery as a last-resort credential provider.
20+
21+
Only activates when CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG are set.
22+
"""
23+
from ..credentials import CredentialContext
24+
from ..credentials.providers import OidcProvider
25+
26+
context = CredentialContext(
27+
api_host=config.host,
28+
debug=config.debug,
29+
proxy=config.proxy,
30+
ssl_verify=config.verify_ssl,
31+
user_agent=config.user_agent,
32+
headers=config.headers.copy() if config.headers else None,
33+
)
34+
35+
provider = OidcProvider()
36+
result = provider.resolve(context)
37+
38+
if result is not None:
39+
config.api_key["X-Api-Key"] = result.api_key
40+
41+
if config.debug:
42+
click.echo(f"OIDC credential resolved: {result.source_detail}")
43+
elif config.debug:
44+
logger.debug("OIDC auto-discovery did not resolve credentials")
45+
1446

1547
def initialise_api(
1648
debug=False,
@@ -104,6 +136,9 @@ def initialise_api(
104136

105137
if config.debug:
106138
click.echo("User API key config value set")
139+
else:
140+
# No access token and no API key provided — try OIDC auto-discovery
141+
_try_oidc_credential(config)
107142

108143
auth_header = headers and config.headers.get("Authorization")
109144
if auth_header and " " in auth_header:

0 commit comments

Comments
 (0)