Skip to content

Commit 31f616c

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 aab4997 commit 31f616c

File tree

10 files changed

+683
-16
lines changed

10 files changed

+683
-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 ..credential_helpers.common 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: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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+
import requests
14+
15+
from ..core.credentials import CredentialContext, CredentialProviderChain
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
def _get_networking_config():
21+
"""Read networking configuration from env vars and the CLI config file.
22+
23+
Sources (in priority order):
24+
1. Environment variables: CLOUDSMITH_API_PROXY,
25+
CLOUDSMITH_WITHOUT_API_SSL_VERIFY, CLOUDSMITH_API_USER_AGENT,
26+
CLOUDSMITH_API_HEADERS
27+
2. CLI config file (config.ini): api_proxy, api_ssl_verify
28+
29+
Returns:
30+
dict with keys: proxy, ssl_verify, user_agent, headers
31+
"""
32+
proxy = os.environ.get("CLOUDSMITH_API_PROXY", "").strip() or None
33+
user_agent = os.environ.get("CLOUDSMITH_API_USER_AGENT", "").strip() or None
34+
35+
# SSL verify: env var is an opt-out flag (presence means disable)
36+
ssl_verify_env = os.environ.get("CLOUDSMITH_WITHOUT_API_SSL_VERIFY", "").strip()
37+
ssl_verify = (
38+
ssl_verify_env.lower() not in ("1", "true", "yes") if ssl_verify_env else True
39+
)
40+
41+
# Parse extra headers from CSV "key=value,key2=value2"
42+
headers = None
43+
headers_env = os.environ.get("CLOUDSMITH_API_HEADERS", "").strip()
44+
if headers_env:
45+
headers = {}
46+
for pair in headers_env.split(","):
47+
if "=" in pair:
48+
k, v = pair.split("=", 1)
49+
headers[k.strip()] = v.strip()
50+
51+
# Fall back to CLI config file for proxy and ssl_verify
52+
if not proxy or ssl_verify is True:
53+
try:
54+
from ..cli.config import ConfigReader
55+
56+
raw_config = ConfigReader.read_config()
57+
defaults = raw_config.get("default", {})
58+
59+
if not proxy:
60+
cfg_proxy = defaults.get("api_proxy", "").strip()
61+
if cfg_proxy:
62+
proxy = cfg_proxy
63+
64+
# Only override ssl_verify if the env var wasn't explicitly set
65+
if not ssl_verify_env:
66+
cfg_ssl = defaults.get("api_ssl_verify", "true").strip().lower()
67+
if cfg_ssl in ("0", "false", "no"):
68+
ssl_verify = False
69+
except Exception: # pylint: disable=broad-exception-caught
70+
logger.debug("Failed to read CLI config for networking", exc_info=True)
71+
72+
return {
73+
"proxy": proxy,
74+
"ssl_verify": ssl_verify,
75+
"user_agent": user_agent,
76+
"headers": headers,
77+
}
78+
79+
80+
def create_session(
81+
api_key=None,
82+
proxy=None,
83+
ssl_verify=True,
84+
user_agent=None,
85+
headers=None,
86+
):
87+
"""Create a requests session with networking and auth configuration.
88+
89+
Shared helper used by credential helpers, custom-domain lookups, and
90+
SAML auth flows so that proxy / TLS / header settings are applied
91+
consistently.
92+
93+
Args:
94+
api_key: Optional API key for Bearer auth.
95+
proxy: HTTP/HTTPS proxy URL.
96+
ssl_verify: Whether to verify SSL certificates.
97+
user_agent: Custom User-Agent string.
98+
headers: Additional headers to include.
99+
100+
Returns:
101+
Configured requests.Session instance.
102+
"""
103+
session = requests.Session()
104+
105+
if proxy:
106+
session.proxies = {"http": proxy, "https": proxy}
107+
108+
session.verify = ssl_verify
109+
110+
if user_agent:
111+
session.headers["User-Agent"] = user_agent
112+
113+
if headers:
114+
session.headers.update(headers)
115+
116+
if api_key:
117+
session.headers["Authorization"] = f"Bearer {api_key}"
118+
119+
return session
120+
121+
122+
def resolve_credentials(debug=False):
123+
"""
124+
Resolve Cloudsmith credentials using the provider chain.
125+
126+
Tries providers in order: environment variables, config file, keyring, OIDC.
127+
Networking configuration is read from env vars and the CLI config file so
128+
that OIDC token exchange works behind proxies.
129+
130+
Args:
131+
debug: Enable debug logging
132+
133+
Returns:
134+
CredentialResult with .api_key, or None if no credentials available
135+
"""
136+
api_host = os.environ.get("CLOUDSMITH_API_HOST", "https://api.cloudsmith.io")
137+
net = _get_networking_config()
138+
139+
context = CredentialContext(
140+
api_host=api_host,
141+
debug=debug,
142+
proxy=net["proxy"],
143+
ssl_verify=net["ssl_verify"],
144+
user_agent=net["user_agent"],
145+
headers=net["headers"],
146+
)
147+
148+
chain = CredentialProviderChain()
149+
result = chain.resolve(context)
150+
151+
if not result or not result.api_key:
152+
return None
153+
154+
return result
155+
156+
157+
def extract_hostname(url):
158+
"""
159+
Extract bare hostname from any URL format.
160+
161+
Handles protocols, sparse+ prefix, ports, paths, and trailing slashes.
162+
163+
Args:
164+
url: URL in any format (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/")
165+
166+
Returns:
167+
str: Lowercase hostname (e.g., "cargo.cloudsmith.io")
168+
"""
169+
if not url:
170+
return ""
171+
172+
normalized = url.lower().strip()
173+
174+
# Remove sparse+ prefix (Cargo)
175+
if normalized.startswith("sparse+"):
176+
normalized = normalized[7:]
177+
178+
# Remove protocol
179+
if "://" in normalized:
180+
normalized = normalized.split("://", 1)[1]
181+
182+
# Remove userinfo (user@host)
183+
if "@" in normalized.split("/")[0]:
184+
normalized = normalized.split("@", 1)[1]
185+
186+
# Extract hostname (before first / or :)
187+
hostname = normalized.split("/")[0].split(":")[0]
188+
189+
return hostname
190+
191+
192+
def is_cloudsmith_domain(url, _credential_result=None):
193+
"""
194+
Check if a URL points to a Cloudsmith service.
195+
196+
Checks standard *.cloudsmith.io domains first (no auth needed).
197+
If not a standard domain, uses the provided credential result (or
198+
resolves credentials) and queries the Cloudsmith API for custom domains.
199+
200+
Args:
201+
url: URL or hostname to check
202+
_credential_result: Pre-resolved CredentialResult to avoid duplicate
203+
credential resolution. If None, resolves credentials internally.
204+
205+
Returns:
206+
bool: True if this is a Cloudsmith domain
207+
"""
208+
hostname = extract_hostname(url)
209+
if not hostname:
210+
return False
211+
212+
# Standard Cloudsmith domains — no auth needed
213+
if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io":
214+
return True
215+
216+
# Custom domains require org + auth
217+
org = os.environ.get("CLOUDSMITH_ORG", "").strip()
218+
if not org:
219+
return False
220+
221+
result = _credential_result or resolve_credentials()
222+
if not result:
223+
return False
224+
225+
from .custom_domains import get_custom_domains_for_org
226+
227+
api_host = os.environ.get("CLOUDSMITH_API_HOST", "https://api.cloudsmith.io")
228+
net = _get_networking_config()
229+
custom_domains = get_custom_domains_for_org(
230+
org,
231+
api_host,
232+
result.api_key,
233+
proxy=net["proxy"],
234+
ssl_verify=net["ssl_verify"],
235+
user_agent=net["user_agent"],
236+
headers=net["headers"],
237+
)
238+
239+
return hostname in [d.lower() for d in custom_domains]

0 commit comments

Comments
 (0)