Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cloudsmith_cli/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@


def _perform_saml_authentication(
opts, owner, enable_token_creation=False, use_stderr=False, no_browser=False
opts,
owner,
enable_token_creation=False,
use_stderr=False,
no_browser=False,
profile=None,
):
"""Perform SAML authentication via web browser and local web server."""
session = create_configured_session(opts)
Expand Down Expand Up @@ -63,6 +68,7 @@ def _perform_saml_authentication(
debug=opts.debug,
refresh_api_on_success=enable_token_creation,
api_opts=opts.api_config,
profile=profile,
)

auth_server.handle_request()
Expand Down Expand Up @@ -187,6 +193,7 @@ def authenticate(
enable_token_creation=enable_token_creation,
use_stderr=use_stderr,
no_browser=no_browser,
profile=ctx.meta.get("profile"),
)

if request_api_key_flag:
Expand Down
8 changes: 4 additions & 4 deletions cloudsmith_cli/cli/commands/logout.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def _clear_credentials(dry_run, use_stderr):
return {"action": action, "files": list(creds_files)}


def _clear_keyring(api_host, dry_run, use_stderr):
def _clear_keyring(api_host, dry_run, use_stderr, profile=None):
"""Clear SSO tokens from keyring. Returns result dict."""
if not keyring.should_use_keyring():
click.secho(
Expand All @@ -43,15 +43,15 @@ def _clear_keyring(api_host, dry_run, use_stderr):
)
return {"action": "disabled"}

if not keyring.has_sso_tokens(api_host):
if not keyring.has_sso_tokens(api_host, profile=profile):
click.echo("No SSO tokens found in system keyring.", err=use_stderr)
return {"action": "not_found"}

if dry_run:
click.echo("Would remove SSO tokens from system keyring.", err=use_stderr)
return {"action": "would_remove"}

deleted = keyring.delete_sso_tokens(api_host)
deleted = keyring.delete_sso_tokens(api_host, profile=profile)
action = "removed" if deleted else "failed"
msg = f"{'Removed' if deleted else 'Failed to remove'} SSO tokens from system keyring."
click.secho(msg, fg=None if deleted else "red", err=use_stderr)
Expand Down Expand Up @@ -131,7 +131,7 @@ def logout(ctx, opts, api_host, keyring_only, config_only, dry_run):
else {"action": "skipped", "files": []}
)
keyring_result = (
_clear_keyring(api_host, dry_run, use_stderr)
_clear_keyring(api_host, dry_run, use_stderr, profile=ctx.meta.get("profile"))
if not config_only
else {"action": "skipped"}
)
Expand Down
18 changes: 12 additions & 6 deletions cloudsmith_cli/cli/commands/whoami.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,15 @@ def _get_api_key_source(opts):
return {"configured": False, "source": None, "source_key": None}


def _get_sso_status(api_host):
def _get_sso_status(api_host, profile=None):
"""Return SSO token status from the system keyring."""
enabled = keyring.should_use_keyring()
has_tokens = enabled and keyring.has_sso_tokens(api_host)
refreshed = keyring.get_refresh_attempted_at(api_host) if has_tokens else None
has_tokens = enabled and keyring.has_sso_tokens(api_host, profile=profile)
refreshed = (
keyring.get_refresh_attempted_at(api_host, profile=profile)
if has_tokens
else None
)

return {
"configured": has_tokens,
Expand All @@ -50,10 +54,10 @@ def _get_sso_status(api_host):
}


def _get_verbose_auth_data(opts, api_host):
def _get_verbose_auth_data(opts, api_host, profile=None):
"""Gather all auth details for verbose output."""
api_key_info = _get_api_key_source(opts)
sso_info = _get_sso_status(api_host)
sso_info = _get_sso_status(api_host, profile=profile)

# Fetch token metadata (extra API call, graceful fallback)
token_meta = None
Expand Down Expand Up @@ -171,7 +175,9 @@ def whoami(ctx, opts):

if opts.verbose:
api_host = getattr(opts.api_config, "host", None) or opts.api_host
data["auth"] = _get_verbose_auth_data(opts, api_host)
data["auth"] = _get_verbose_auth_data(
opts, api_host, profile=ctx.meta.get("profile")
)

if utils.maybe_print_as_json(opts, data):
if not is_auth:
Expand Down
7 changes: 4 additions & 3 deletions cloudsmith_cli/cli/tests/commands/test_logout.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ def runner():

@pytest.fixture
def mock_no_keyring_env():
"""Ensure CLOUDSMITH_NO_KEYRING and CLOUDSMITH_API_KEY are not set."""
"""Ensure CLOUDSMITH_NO_KEYRING, CLOUDSMITH_API_KEY and CLOUDSMITH_PROFILE are not set."""
env = os.environ.copy()
env.pop("CLOUDSMITH_NO_KEYRING", None)
env.pop("CLOUDSMITH_API_KEY", None)
env.pop("CLOUDSMITH_PROFILE", None)
with patch.dict(os.environ, env, clear=True):
yield

Expand Down Expand Up @@ -49,7 +50,7 @@ def test_full_logout(self, runner, mock_deps):

assert result.exit_code == 0
mock_creds.clear_api_key.assert_called_once_with(CREDS_PATH)
mock_keyring.delete_sso_tokens.assert_called_once_with(HOST)
mock_keyring.delete_sso_tokens.assert_called_once_with(HOST, profile=None)
assert "Removed credentials from:" in result.output
assert "Removed SSO tokens from system keyring" in result.output

Expand All @@ -59,7 +60,7 @@ def test_misconfigured_api_host_is_normalized(self, runner, mock_deps):
result = runner.invoke(logout, ["--api-host", " api.example.com/ "])

assert result.exit_code == 0
mock_keyring.delete_sso_tokens.assert_called_once_with(HOST)
mock_keyring.delete_sso_tokens.assert_called_once_with(HOST, profile=None)

def test_dry_run(self, runner, mock_deps):
mock_creds, mock_keyring = mock_deps
Expand Down
36 changes: 36 additions & 0 deletions cloudsmith_cli/cli/tests/test_webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def mock_handler(self):
)
handler.server_instance = MagicMock()
handler.server_instance.api_host = "https://api.cloudsmith.io"
handler.server_instance.profile = None
handler.refresh_api_on_success = False
handler.session = MagicMock()
handler.debug = False
Expand Down Expand Up @@ -99,6 +100,41 @@ def test_store_sso_tokens_called_when_keyring_enabled(self, mock_handler):
"https://api.cloudsmith.io",
"test_access_token",
"test_refresh_token",
profile=None,
)

def test_store_sso_tokens_receives_profile(self, mock_handler):
"""Verify store_sso_tokens receives the profile from the server."""
mock_handler.server_instance.profile = "staging"
with (
patch(
"cloudsmith_cli.cli.webserver.store_sso_tokens", return_value=True
) as mock_store,
patch.object(mock_handler, "_return_success_response"),
patch.object(
AuthenticationWebRequestHandler,
"query_data",
new_callable=PropertyMock,
) as mock_query,
patch.object(
AuthenticationWebRequestHandler,
"api_host",
new_callable=PropertyMock,
) as mock_host,
):
mock_query.return_value = {
"access_token": "test_access_token",
"refresh_token": "test_refresh_token",
}
mock_host.return_value = "https://api.cloudsmith.io"

mock_handler.do_GET()

mock_store.assert_called_once_with(
"https://api.cloudsmith.io",
"test_access_token",
"test_refresh_token",
profile="staging",
)

def test_message_shown_when_keyring_disabled(self, mock_handler):
Expand Down
54 changes: 26 additions & 28 deletions cloudsmith_cli/cli/webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def __init__(
self.debug = kwargs.get("debug", False)
self.refresh_api_on_success = kwargs.get("refresh_api_on_success", False)
self.api_opts = kwargs.get("api_opts")
self.profile = kwargs.get("profile")
self.sso_access_token = None
self.exception = None

Expand Down Expand Up @@ -138,6 +139,11 @@ def api_host(self):
"""Get the API host from the server instance."""
return self.server_instance.api_host if self.server_instance else None

@property
def profile(self):
"""Get the profile from the server instance."""
return self.server_instance.profile if self.server_instance else None

def _prompt_and_exchange_2fa_token(self, two_factor_token):
"""Prompt for a 2FA code, and prompt again while the API rejects it."""
click.echo(err=True)
Expand Down Expand Up @@ -229,6 +235,24 @@ def url(self):
def query_data(self):
return dict(parse_qsl(self.url.query))

def _store_authentication_result(self, access_token, refresh_token):
# Store the access token on the server instance so it can be
# passed directly to initialise_api(), avoiding a keyring
# roundtrip (critical when CLOUDSMITH_NO_KEYRING is set).
if self.server_instance:
self.server_instance.sso_access_token = access_token

if not store_sso_tokens(
self.api_host, access_token, refresh_token, profile=self.profile
):
click.echo(
"SSO tokens not stored (CLOUDSMITH_NO_KEYRING is set)",
err=True,
)

if self.refresh_api_on_success and self.server_instance:
self.server_instance.refresh_api_config_after_auth()

def do_GET(self):
access_token = self.query_data.get("access_token")
refresh_token = self.query_data.get("refresh_token")
Expand All @@ -244,21 +268,7 @@ def do_GET(self):

try:
if access_token:
# Store the access token on the server instance so it can be
# passed directly to initialise_api(), avoiding a keyring
# roundtrip (critical when CLOUDSMITH_NO_KEYRING is set).
if self.server_instance:
self.server_instance.sso_access_token = access_token

if not store_sso_tokens(self.api_host, access_token, refresh_token):
click.echo(
"SSO tokens not stored (CLOUDSMITH_NO_KEYRING is set)",
err=True,
)

if self.refresh_api_on_success and self.server_instance:
self.server_instance.refresh_api_config_after_auth()

self._store_authentication_result(access_token, refresh_token)
self._return_success_response()
return

Expand All @@ -272,19 +282,7 @@ def do_GET(self):
two_factor_token
)

# Store the access token on the server instance (same as above)
if self.server_instance:
self.server_instance.sso_access_token = access_token

if not store_sso_tokens(self.api_host, access_token, refresh_token):
click.echo(
"SSO tokens not stored (CLOUDSMITH_NO_KEYRING is set)",
err=True,
)

if self.refresh_api_on_success and self.server_instance:
self.server_instance.refresh_api_config_after_auth()

self._store_authentication_result(access_token, refresh_token)
click.secho("\nAuthentication complete", fg="green", err=True)
return
except Exception:
Expand Down
16 changes: 8 additions & 8 deletions cloudsmith_cli/core/credentials/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,6 @@
import logging
from typing import TYPE_CHECKING

from .providers import (
CLIFlagProvider,
CredentialsFileProvider,
EnvVarProvider,
KeyringProvider,
OidcProvider,
)

if TYPE_CHECKING:
from .models import CredentialContext, CredentialResult
from .provider import CredentialProvider
Expand All @@ -35,6 +27,14 @@ def __init__(self, providers: list[CredentialProvider] | None = None):
if providers is not None:
self.providers = providers
else:
from .providers import (
CLIFlagProvider,
CredentialsFileProvider,
EnvVarProvider,
KeyringProvider,
OidcProvider,
)

self.providers = [
CLIFlagProvider(),
EnvVarProvider(),
Expand Down
Loading
Loading