Skip to content

Commit 0e03731

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 646c50a commit 0e03731

File tree

10 files changed

+543
-16
lines changed

10 files changed

+543
-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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Shared utilities for credential helpers.
3+
4+
Provides credential resolution and domain checking used by all helpers.
5+
Configuration is resolved via :class:`ConfigResolver` which reads from
6+
environment variables, config files, and defaults — the same sources
7+
used by the main CLI.
8+
"""
9+
10+
import logging
11+
12+
from ..core.config_resolver import ConfigResolver
13+
from ..core.credentials import CredentialContext, CredentialProviderChain
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
def resolve_credentials(debug=False):
19+
"""Resolve Cloudsmith credentials using the provider chain.
20+
21+
Configuration (proxy, SSL, headers, etc.) is resolved automatically
22+
from environment variables and config files via :class:`ConfigResolver`.
23+
24+
Args:
25+
debug: Enable debug logging
26+
27+
Returns:
28+
CredentialResult with .api_key, or None if no credentials available
29+
"""
30+
config = ConfigResolver().resolve(debug=debug)
31+
context = CredentialContext(config=config, debug=debug)
32+
33+
chain = CredentialProviderChain()
34+
result = chain.resolve(context)
35+
36+
if not result or not result.api_key:
37+
return None
38+
39+
return result
40+
41+
42+
def extract_hostname(url):
43+
"""
44+
Extract bare hostname from any URL format.
45+
46+
Handles protocols, sparse+ prefix, ports, paths, and trailing slashes.
47+
48+
Args:
49+
url: URL in any format (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/")
50+
51+
Returns:
52+
str: Lowercase hostname (e.g., "cargo.cloudsmith.io")
53+
"""
54+
if not url:
55+
return ""
56+
57+
normalized = url.lower().strip()
58+
59+
# Remove sparse+ prefix (Cargo)
60+
if normalized.startswith("sparse+"):
61+
normalized = normalized[7:]
62+
63+
# Remove protocol
64+
if "://" in normalized:
65+
normalized = normalized.split("://", 1)[1]
66+
67+
# Remove userinfo (user@host)
68+
if "@" in normalized.split("/")[0]:
69+
normalized = normalized.split("@", 1)[1]
70+
71+
# Extract hostname (before first / or :)
72+
hostname = normalized.split("/")[0].split(":")[0]
73+
74+
return hostname
75+
76+
77+
def is_cloudsmith_domain(url, _credential_result=None):
78+
"""
79+
Check if a URL points to a Cloudsmith service.
80+
81+
Checks standard *.cloudsmith.io domains first (no auth needed).
82+
If not a standard domain, uses the provided credential result (or
83+
resolves credentials) and queries the Cloudsmith API for custom domains.
84+
85+
Args:
86+
url: URL or hostname to check
87+
_credential_result: Pre-resolved CredentialResult to avoid duplicate
88+
credential resolution. If None, resolves credentials internally.
89+
90+
Returns:
91+
bool: True if this is a Cloudsmith domain
92+
"""
93+
hostname = extract_hostname(url)
94+
if not hostname:
95+
return False
96+
97+
# Standard Cloudsmith domains — no auth needed
98+
if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io":
99+
return True
100+
101+
# Custom domains require org + auth
102+
config = ConfigResolver().resolve()
103+
org = config.oidc_org
104+
if not org:
105+
return False
106+
107+
result = _credential_result or resolve_credentials()
108+
if not result:
109+
return False
110+
111+
from .custom_domains import get_custom_domains_for_org
112+
113+
custom_domains = get_custom_domains_for_org(org, config, result.api_key)
114+
115+
return hostname in [d.lower() for d in custom_domains]

0 commit comments

Comments
 (0)