Skip to content

Commit 58327a3

Browse files
feat: add Docker credential helper for Cloudsmith registries
Implement the Docker credential helper protocol so Docker can automatically authenticate with Cloudsmith registries (including custom domains) without manual `docker login`. Key changes: - Add `cloudsmith credential-helper docker` CLI command - Add `docker-credential-cloudsmith` wrapper binary (entry point) - Add credential resolution using the existing provider chain (OIDC, API keys, config, keyring) - Add custom domain discovery via Cloudsmith API with filesystem caching - Extract shared `create_session` helper, reused by SAML auth flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fd065ae commit 58327a3

File tree

10 files changed

+639
-16
lines changed

10 files changed

+639
-16
lines changed

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
auth,
55
check,
66
copy,
7+
credential_helper,
78
delete,
89
dependencies,
910
docs,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Credential helper commands for Cloudsmith.
3+
4+
This module provides credential helper commands for package managers
5+
that follow their respective credential helper protocols.
6+
"""
7+
8+
import click
9+
10+
from ..main import main
11+
from .docker import docker as docker_cmd
12+
13+
14+
@click.group()
15+
def credential_helper():
16+
"""
17+
Credential helpers for package managers.
18+
19+
These commands provide credentials for package managers like Docker.
20+
They are typically called by wrapper binaries
21+
(e.g., docker-credential-cloudsmith) or used directly for debugging.
22+
23+
Examples:
24+
# Test Docker credential helper
25+
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
26+
"""
27+
28+
29+
credential_helper.add_command(docker_cmd, name="docker")
30+
31+
main.add_command(credential_helper, name="credential-helper")
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""
2+
Docker credential helper command.
3+
4+
Implements the Docker credential helper protocol for Cloudsmith registries.
5+
6+
See: https://github.com/docker/docker-credential-helpers
7+
"""
8+
9+
import json
10+
import sys
11+
12+
import click
13+
14+
from ....credential_helpers.docker import get_credentials
15+
16+
17+
@click.command()
18+
def docker():
19+
"""
20+
Docker credential helper for Cloudsmith registries.
21+
22+
Reads a Docker registry server URL from stdin and returns credentials in JSON format.
23+
This command implements the 'get' operation of the Docker credential helper protocol.
24+
25+
Only provides credentials for Cloudsmith Docker registries (docker.cloudsmith.io).
26+
27+
Input (stdin):
28+
Server URL as plain text (e.g., "docker.cloudsmith.io")
29+
30+
Output (stdout):
31+
JSON: {"Username": "token", "Secret": "<cloudsmith-token>"}
32+
33+
Exit codes:
34+
0: Success
35+
1: Error (no credentials available, not a Cloudsmith registry, etc.)
36+
37+
Examples:
38+
# Manual testing
39+
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
40+
{"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."}
41+
42+
# Called by Docker via wrapper
43+
$ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get
44+
{"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."}
45+
46+
Environment variables:
47+
CLOUDSMITH_API_KEY: API key for authentication (optional)
48+
CLOUDSMITH_ORG: Organization slug (required for custom domain support)
49+
"""
50+
try:
51+
server_url = sys.stdin.read().strip()
52+
53+
if not server_url:
54+
click.echo("Error: No server URL provided on stdin", err=True)
55+
sys.exit(1)
56+
57+
credentials = get_credentials(server_url, debug=False)
58+
59+
if not credentials:
60+
click.echo(
61+
"Error: Unable to retrieve credentials. "
62+
"Make sure you have a valid cloudsmith-cli session, "
63+
"this can be checked with `cloudsmith whoami`.",
64+
err=True,
65+
)
66+
sys.exit(1)
67+
68+
click.echo(json.dumps(credentials))
69+
70+
except Exception as e: # pylint: disable=broad-exception-caught
71+
click.echo(f"Error: {e}", err=True)
72+
sys.exit(1)

cloudsmith_cli/cli/saml.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,19 @@
33
import requests
44

55
from ..core.api.exceptions import ApiException
6+
from ..core.credentials.session import create_session
67

78

89
def create_configured_session(opts):
910
"""
1011
Create a requests session configured with the options from opts.
1112
"""
12-
session = requests.Session()
13-
14-
if hasattr(opts, "api_ssl_verify") and opts.api_ssl_verify is not None:
15-
session.verify = opts.api_ssl_verify
16-
17-
if hasattr(opts, "api_proxy") and opts.api_proxy:
18-
session.proxies = {"http": opts.api_proxy, "https": opts.api_proxy}
19-
20-
if hasattr(opts, "api_user_agent") and opts.api_user_agent:
21-
session.headers.update({"User-Agent": opts.api_user_agent})
22-
23-
if hasattr(opts, "api_headers") and opts.api_headers:
24-
session.headers.update(opts.api_headers)
25-
26-
return session
13+
return create_session(
14+
proxy=getattr(opts, "api_proxy", None),
15+
ssl_verify=getattr(opts, "api_ssl_verify", True),
16+
user_agent=getattr(opts, "api_user_agent", None),
17+
headers=getattr(opts, "api_headers", None),
18+
)
2719

2820

2921
def get_idp_url(api_host, owner, session):
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Credential helpers for various package managers.
3+
4+
This package provides credential helper implementations for Docker, pip, npm, etc.
5+
Each helper follows its respective package manager's credential helper protocol.
6+
"""
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""
2+
Shared utilities for credential helpers.
3+
4+
Provides credential resolution and domain checking used by all helpers.
5+
Networking configuration (proxy, TLS, headers) is read from the same
6+
environment variables and config files used by the main CLI so that
7+
credential helpers work correctly behind proxies and with custom certs.
8+
"""
9+
10+
import logging
11+
import os
12+
13+
from ..core.credentials import CredentialContext, CredentialProviderChain
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def _get_networking_config():
19+
"""Read networking configuration from env vars and the CLI config file.
20+
21+
Sources (in priority order):
22+
1. Environment variables: CLOUDSMITH_API_PROXY,
23+
CLOUDSMITH_WITHOUT_API_SSL_VERIFY, CLOUDSMITH_API_USER_AGENT,
24+
CLOUDSMITH_API_HEADERS
25+
2. CLI config file (config.ini): api_proxy, api_ssl_verify
26+
27+
Returns:
28+
dict with keys: proxy, ssl_verify, user_agent, headers
29+
"""
30+
proxy = os.environ.get("CLOUDSMITH_API_PROXY", "").strip() or None
31+
user_agent = os.environ.get("CLOUDSMITH_API_USER_AGENT", "").strip() or None
32+
33+
# SSL verify: env var is an opt-out flag (presence means disable)
34+
ssl_verify_env = os.environ.get("CLOUDSMITH_WITHOUT_API_SSL_VERIFY", "").strip()
35+
ssl_verify = (
36+
ssl_verify_env.lower() not in ("1", "true", "yes") if ssl_verify_env else True
37+
)
38+
39+
# Parse extra headers from CSV "key=value,key2=value2"
40+
headers = None
41+
headers_env = os.environ.get("CLOUDSMITH_API_HEADERS", "").strip()
42+
if headers_env:
43+
headers = {}
44+
for pair in headers_env.split(","):
45+
if "=" in pair:
46+
k, v = pair.split("=", 1)
47+
headers[k.strip()] = v.strip()
48+
49+
# Fall back to CLI config file for proxy and ssl_verify
50+
if not proxy or ssl_verify is True:
51+
try:
52+
from ..cli.config import ConfigReader
53+
54+
raw_config = ConfigReader.read_config()
55+
defaults = raw_config.get("default", {})
56+
57+
if not proxy:
58+
cfg_proxy = defaults.get("api_proxy", "").strip()
59+
if cfg_proxy:
60+
proxy = cfg_proxy
61+
62+
# Only override ssl_verify if the env var wasn't explicitly set
63+
if not ssl_verify_env:
64+
cfg_ssl = defaults.get("api_ssl_verify", "true").strip().lower()
65+
if cfg_ssl in ("0", "false", "no"):
66+
ssl_verify = False
67+
except Exception: # pylint: disable=broad-exception-caught
68+
logger.debug("Failed to read CLI config for networking", exc_info=True)
69+
70+
return {
71+
"proxy": proxy,
72+
"ssl_verify": ssl_verify,
73+
"user_agent": user_agent,
74+
"headers": headers,
75+
}
76+
77+
78+
def resolve_credentials(debug=False):
79+
"""
80+
Resolve Cloudsmith credentials using the provider chain.
81+
82+
Tries providers in order: environment variables, config file, keyring, OIDC.
83+
Networking configuration is read from env vars and the CLI config file so
84+
that OIDC token exchange works behind proxies.
85+
86+
Args:
87+
debug: Enable debug logging
88+
89+
Returns:
90+
CredentialResult with .api_key, or None if no credentials available
91+
"""
92+
api_host = os.environ.get("CLOUDSMITH_API_HOST", "https://api.cloudsmith.io")
93+
net = _get_networking_config()
94+
95+
context = CredentialContext(
96+
api_host=api_host,
97+
debug=debug,
98+
proxy=net["proxy"],
99+
ssl_verify=net["ssl_verify"],
100+
user_agent=net["user_agent"],
101+
headers=net["headers"],
102+
)
103+
104+
chain = CredentialProviderChain()
105+
result = chain.resolve(context)
106+
107+
if not result or not result.api_key:
108+
return None
109+
110+
return result
111+
112+
113+
def extract_hostname(url):
114+
"""
115+
Extract bare hostname from any URL format.
116+
117+
Handles protocols, sparse+ prefix, ports, paths, and trailing slashes.
118+
119+
Args:
120+
url: URL in any format (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/")
121+
122+
Returns:
123+
str: Lowercase hostname (e.g., "cargo.cloudsmith.io")
124+
"""
125+
if not url:
126+
return ""
127+
128+
normalized = url.lower().strip()
129+
130+
# Remove sparse+ prefix (Cargo)
131+
if normalized.startswith("sparse+"):
132+
normalized = normalized[7:]
133+
134+
# Remove protocol
135+
if "://" in normalized:
136+
normalized = normalized.split("://", 1)[1]
137+
138+
# Remove userinfo (user@host)
139+
if "@" in normalized.split("/")[0]:
140+
normalized = normalized.split("@", 1)[1]
141+
142+
# Extract hostname (before first / or :)
143+
hostname = normalized.split("/")[0].split(":")[0]
144+
145+
return hostname
146+
147+
148+
def is_cloudsmith_domain(url, _credential_result=None):
149+
"""
150+
Check if a URL points to a Cloudsmith service.
151+
152+
Checks standard *.cloudsmith.io domains first (no auth needed).
153+
If not a standard domain, uses the provided credential result (or
154+
resolves credentials) and queries the Cloudsmith API for custom domains.
155+
156+
Args:
157+
url: URL or hostname to check
158+
_credential_result: Pre-resolved CredentialResult to avoid duplicate
159+
credential resolution. If None, resolves credentials internally.
160+
161+
Returns:
162+
bool: True if this is a Cloudsmith domain
163+
"""
164+
hostname = extract_hostname(url)
165+
if not hostname:
166+
return False
167+
168+
# Standard Cloudsmith domains — no auth needed
169+
if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io":
170+
return True
171+
172+
# Custom domains require org + auth
173+
org = os.environ.get("CLOUDSMITH_ORG", "").strip()
174+
if not org:
175+
return False
176+
177+
result = _credential_result or resolve_credentials()
178+
if not result:
179+
return False
180+
181+
from .custom_domains import get_custom_domains_for_org
182+
183+
api_host = os.environ.get("CLOUDSMITH_API_HOST", "https://api.cloudsmith.io")
184+
net = _get_networking_config()
185+
custom_domains = get_custom_domains_for_org(
186+
org,
187+
api_host,
188+
result.api_key,
189+
proxy=net["proxy"],
190+
ssl_verify=net["ssl_verify"],
191+
user_agent=net["user_agent"],
192+
headers=net["headers"],
193+
)
194+
195+
return hostname in [d.lower() for d in custom_domains]

0 commit comments

Comments
 (0)