Skip to content

Commit b4b2583

Browse files
feat: add credential provider chain concept
1 parent 2a47c30 commit b4b2583

File tree

17 files changed

+637
-426
lines changed

17 files changed

+637
-426
lines changed

cloudsmith_cli/cli/commands/whoami.py

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
"""CLI/Commands - Retrieve authentication status."""
22

3-
import os
4-
53
import click
64

75
from ...core import keyring
86
from ...core.api.exceptions import ApiException
97
from ...core.api.user import get_token_metadata, get_user_brief
108
from .. import decorators, utils
11-
from ..config import CredentialsReader
129
from ..exceptions import handle_api_exceptions
1310
from .main import main
1411

@@ -26,26 +23,17 @@ def _get_active_method(api_config):
2623
def _get_api_key_source(opts):
2724
"""Determine where the API key was loaded from.
2825
29-
Checks in priority order matching actual resolution:
30-
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini.
26+
Uses the credential provider chain result attached by initialise_api.
3127
"""
32-
if not opts.api_key:
33-
return {"configured": False, "source": None, "source_key": None}
34-
35-
env_key = os.environ.get("CLOUDSMITH_API_KEY")
36-
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"
40-
elif env_key:
41-
suffix = env_key[-4:]
42-
source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var"
43-
elif creds := CredentialsReader.find_existing_files():
44-
source, key = f"credentials.ini ({creds[0]})", "credentials_file"
45-
else:
46-
source, key = "CLI --api-key flag", "cli_flag"
47-
48-
return {"configured": True, "source": source, "source_key": key}
28+
credential = getattr(opts, "credential", None)
29+
if credential:
30+
return {
31+
"configured": True,
32+
"source": credential.source_detail or credential.source_name,
33+
"source_key": credential.source_name,
34+
}
35+
36+
return {"configured": False, "source": None, "source_key": None}
4937

5038

5139
def _get_sso_status(api_host):

cloudsmith_cli/cli/decorators.py

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from cloudsmith_cli.cli import validators
88

99
from ..core.api.init import initialise_api as _initialise_api
10+
from ..core.credentials import CredentialContext, CredentialProviderChain
11+
from ..core.credentials.session import create_session as _create_session
1012
from ..core.mcp import server
1113
from . import config, utils
1214

@@ -20,6 +22,14 @@ def report_retry(seconds, context=None):
2022
)
2123

2224

25+
def _pop_boolean_flag(kwargs, name, invert=False):
26+
"""Pop a boolean flag from kwargs, optionally inverting it."""
27+
value = kwargs.pop(name)
28+
if value is not None and invert:
29+
value = not value
30+
return value
31+
32+
2333
def common_package_action_options(f):
2434
"""Add common options for package actions."""
2535

@@ -214,15 +224,17 @@ def common_api_auth_options(f):
214224
def wrapper(ctx, *args, **kwargs):
215225
# pylint: disable=missing-docstring
216226
opts = config.get_or_create_options(ctx)
217-
opts.api_key = kwargs.pop("api_key")
227+
api_key = kwargs.pop("api_key")
228+
if api_key:
229+
opts.api_key = api_key
218230
kwargs["opts"] = opts
219231
return ctx.invoke(f, *args, **kwargs)
220232

221233
return wrapper
222234

223235

224-
def initialise_api(f):
225-
"""Initialise the Cloudsmith API for use."""
236+
def initialise_session(f):
237+
"""Create a shared HTTP session with proxy/SSL/user-agent settings."""
226238

227239
@click.option(
228240
"--api-host", envvar="CLOUDSMITH_API_HOST", help="The API host to connect to."
@@ -252,6 +264,78 @@ def initialise_api(f):
252264
envvar="CLOUDSMITH_API_HEADERS",
253265
help="A CSV list of extra headers (key=value) to send to the API.",
254266
)
267+
@click.pass_context
268+
@functools.wraps(f)
269+
def wrapper(ctx, *args, **kwargs):
270+
# pylint: disable=missing-docstring
271+
opts = config.get_or_create_options(ctx)
272+
opts.api_host = kwargs.pop("api_host")
273+
opts.api_proxy = kwargs.pop("api_proxy")
274+
opts.api_ssl_verify = _pop_boolean_flag(
275+
kwargs, "without_api_ssl_verify", invert=True
276+
)
277+
opts.api_user_agent = kwargs.pop("api_user_agent")
278+
opts.api_headers = kwargs.pop("api_headers")
279+
280+
opts.session = _create_session(
281+
proxy=opts.api_proxy,
282+
ssl_verify=opts.api_ssl_verify,
283+
user_agent=opts.api_user_agent,
284+
headers=opts.api_headers,
285+
)
286+
287+
kwargs["opts"] = opts
288+
return ctx.invoke(f, *args, **kwargs)
289+
290+
return wrapper
291+
292+
293+
def resolve_credentials(f):
294+
"""Resolve credentials via the provider chain. Depends on initialise_session."""
295+
296+
@click.pass_context
297+
@functools.wraps(f)
298+
def wrapper(ctx, *args, **kwargs):
299+
# pylint: disable=missing-docstring
300+
opts = config.get_or_create_options(ctx)
301+
302+
context = CredentialContext(
303+
session=opts.session,
304+
api_key=opts.api_key,
305+
api_host=opts.api_host or "https://api.cloudsmith.io",
306+
creds_file_path=ctx.meta.get("creds_file"),
307+
profile=ctx.meta.get("profile"),
308+
debug=opts.debug,
309+
)
310+
311+
chain = CredentialProviderChain()
312+
credential = chain.resolve(context)
313+
314+
if context.keyring_refresh_failed:
315+
click.secho(
316+
"An error occurred when attempting to refresh your SSO access token. "
317+
"To refresh this session, run 'cloudsmith auth'",
318+
fg="yellow",
319+
err=True,
320+
)
321+
if credential:
322+
click.secho(
323+
"Falling back to API key authentication.",
324+
fg="yellow",
325+
err=True,
326+
)
327+
328+
opts.credential = credential
329+
330+
kwargs["opts"] = opts
331+
return ctx.invoke(f, *args, **kwargs)
332+
333+
return initialise_session(wrapper)
334+
335+
336+
def initialise_api(f):
337+
"""Initialise the Cloudsmith API for use. Depends on resolve_credentials."""
338+
255339
@click.option(
256340
"-R",
257341
"--without-rate-limit",
@@ -294,20 +378,8 @@ def initialise_api(f):
294378
@functools.wraps(f)
295379
def wrapper(ctx, *args, **kwargs):
296380
# pylint: disable=missing-docstring
297-
def _set_boolean(name, invert=False):
298-
value = kwargs.pop(name)
299-
value = value if value is not None else None
300-
if value is not None and invert:
301-
value = not value
302-
return value
303-
304381
opts = config.get_or_create_options(ctx)
305-
opts.api_host = kwargs.pop("api_host")
306-
opts.api_proxy = kwargs.pop("api_proxy")
307-
opts.api_ssl_verify = _set_boolean("without_api_ssl_verify", invert=True)
308-
opts.api_user_agent = kwargs.pop("api_user_agent")
309-
opts.api_headers = kwargs.pop("api_headers")
310-
opts.rate_limit = _set_boolean("without_rate_limit", invert=True)
382+
opts.rate_limit = _pop_boolean_flag(kwargs, "without_rate_limit", invert=True)
311383
opts.rate_limit_warning = kwargs.pop("rate_limit_warning")
312384
opts.error_retry_max = kwargs.pop("error_retry_max")
313385
opts.error_retry_backoff = kwargs.pop("error_retry_backoff")
@@ -320,7 +392,7 @@ def call_print_rate_limit_info_with_opts(rate_info):
320392
opts.api_config = _initialise_api(
321393
debug=opts.debug,
322394
host=opts.api_host,
323-
key=opts.api_key,
395+
credential=opts.credential,
324396
proxy=opts.api_proxy,
325397
ssl_verify=opts.api_ssl_verify,
326398
user_agent=opts.api_user_agent,
@@ -336,7 +408,7 @@ def call_print_rate_limit_info_with_opts(rate_info):
336408
kwargs["opts"] = opts
337409
return ctx.invoke(f, *args, **kwargs)
338410

339-
return wrapper
411+
return resolve_credentials(wrapper)
340412

341413

342414
def initialise_mcp(f):

cloudsmith_cli/cli/tests/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ...core.api.init import initialise_api
77
from ...core.api.repos import create_repo, delete_repo
8+
from ...core.credentials import CredentialResult
89
from .utils import random_str
910

1011

@@ -51,7 +52,9 @@ def organization():
5152
@pytest.fixture()
5253
def tmp_repository(organization, api_host, api_key):
5354
"""Yield a temporary repository."""
54-
initialise_api(host=api_host, key=api_key)
55+
initialise_api(
56+
host=api_host, credential=CredentialResult(api_key=api_key, source_name="test")
57+
)
5558
repo_data = create_repo(organization, {"name": random_str()})
5659
yield repo_data
5760
delete_repo(organization, repo_data["slug"])

cloudsmith_cli/cli/tests/test_webserver.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ def test_refresh_api_config_passes_sso_token(self):
4040

4141
mock_init_api.assert_called_once()
4242
call_kwargs = mock_init_api.call_args.kwargs
43-
assert call_kwargs.get("access_token") == "test_sso_token_123"
43+
credential = call_kwargs.get("credential")
44+
assert credential is not None
45+
assert credential.api_key == "test_sso_token_123"
46+
assert credential.auth_type == "bearer"
47+
assert credential.source_name == "sso"
4448

4549

4650
class TestAuthenticationWebRequestHandlerKeyring:

cloudsmith_cli/cli/webserver.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ..core.api.exceptions import ApiException
1111
from ..core.api.init import initialise_api
12+
from ..core.credentials import CredentialResult
1213
from ..core.keyring import store_sso_tokens
1314
from .saml import exchange_2fa_token
1415

@@ -79,7 +80,15 @@ def refresh_api_config_after_auth(self):
7980
user_agent=getattr(self.api_opts, "user_agent", None),
8081
headers=getattr(self.api_opts, "headers", None),
8182
rate_limit=getattr(self.api_opts, "rate_limit", True),
82-
access_token=self.sso_access_token,
83+
credential=(
84+
CredentialResult(
85+
api_key=self.sso_access_token,
86+
source_name="sso",
87+
auth_type="bearer",
88+
)
89+
if self.sso_access_token
90+
else None
91+
),
8392
)
8493

8594
def finish_request(self, request, client_address):

cloudsmith_cli/core/api/init.py

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,13 @@
66
import click
77
import cloudsmith_api
88

9-
from ...cli import saml
10-
from .. import keyring
119
from ..rest import RestClient
12-
from .exceptions import ApiException
1310

1411

1512
def initialise_api(
1613
debug=False,
1714
host=None,
18-
key=None,
15+
credential=None,
1916
proxy=None,
2017
ssl_verify=True,
2118
user_agent=None,
@@ -26,7 +23,6 @@ def initialise_api(
2623
error_retry_backoff=None,
2724
error_retry_codes=None,
2825
error_retry_cb=None,
29-
access_token=None,
3026
):
3127
"""Initialise the cloudsmith_api.Configuration."""
3228
# FIXME: pylint: disable=too-many-arguments
@@ -45,65 +41,15 @@ def initialise_api(
4541
config.verify_ssl = ssl_verify
4642
config.client_side_validation = False
4743

48-
# Use directly provided access token (e.g. from SSO callback),
49-
# or fall back to keyring lookup if enabled.
50-
if not access_token:
51-
access_token = keyring.get_access_token(config.host)
52-
53-
if access_token:
54-
auth_header = config.headers.get("Authorization")
55-
56-
# overwrite auth header if empty or is basic auth without username or password
57-
if not auth_header or auth_header == config.get_basic_auth_token():
58-
refresh_token = keyring.get_refresh_token(config.host)
59-
60-
try:
61-
if keyring.should_refresh_access_token(config.host):
62-
new_access_token, new_refresh_token = saml.refresh_access_token(
63-
config.host,
64-
access_token,
65-
refresh_token,
66-
session=saml.create_configured_session(config),
67-
)
68-
keyring.store_sso_tokens(
69-
config.host, new_access_token, new_refresh_token
70-
)
71-
# Use the new tokens
72-
access_token = new_access_token
73-
except ApiException:
74-
keyring.update_refresh_attempted_at(config.host)
75-
76-
click.secho(
77-
"An error occurred when attempting to refresh your SSO access token. To refresh this session, run 'cloudsmith auth'",
78-
fg="yellow",
79-
err=True,
80-
)
81-
82-
# Clear access_token to prevent using expired token
83-
access_token = None
84-
85-
# Fall back to API key auth if available
86-
if key:
87-
click.secho(
88-
"Falling back to API key authentication.",
89-
fg="yellow",
90-
err=True,
91-
)
92-
config.api_key["X-Api-Key"] = key
93-
94-
# Only use SSO token if refresh didn't fail
95-
if access_token:
96-
config.headers["Authorization"] = "Bearer {access_token}".format(
97-
access_token=access_token
98-
)
99-
100-
if config.debug:
101-
click.echo("SSO access token config value set")
102-
elif key:
103-
config.api_key["X-Api-Key"] = key
104-
105-
if config.debug:
106-
click.echo("User API key config value set")
44+
if credential:
45+
if credential.auth_type == "bearer":
46+
config.headers["Authorization"] = f"Bearer {credential.api_key}"
47+
if config.debug:
48+
click.echo("SSO access token config value set")
49+
else:
50+
config.api_key["X-Api-Key"] = credential.api_key
51+
if config.debug:
52+
click.echo("User API key config value set")
10753

10854
auth_header = headers and config.headers.get("Authorization")
10955
if auth_header and " " in auth_header:

0 commit comments

Comments
 (0)