From a551611f0febd3c37a26e8525d7abfd8641959fe Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 3 Sep 2026 00:32:27 +0100 Subject: [PATCH] Reuse existing SSO auth sessions --- CHANGELOG.md | 2 + cloudsmith_cli/cli/commands/auth.py | 63 ++++++++++ .../cli/tests/commands/test_auth.py | 115 +++++++++++++++++- 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 790c6efe..3b468c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed - `cloudsmith domains list` now includes the Workspace slug in a `workspace` field. +- `cloudsmith auth` now reuses an existing SSO session when it can be renewed. +- `cloudsmith auth` now reports the SSO access-token expiry in normal and JSON output. ### Fixed diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index 1bfbd599..de344a81 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -3,7 +3,13 @@ import webbrowser import click +import requests +from ...core.sso import ( + SsoRenewalStatus, + get_access_token_expiry, + renew_sso_session, +) from .. import decorators, utils, validators from ..exceptions import handle_api_exceptions from ..saml import create_configured_session, get_idp_url @@ -16,6 +22,55 @@ AUTH_SERVER_PORT = 12400 +def _renew_existing_sso_session(opts, profile): + """Return a usable existing SSO session, or fall back to browser auth.""" + renewal = renew_sso_session(opts.api_config.host, opts.session, profile=profile) + if renewal.status in ( + SsoRenewalStatus.RENEWED, + SsoRenewalStatus.CURRENT, + ): + return renewal + if renewal.status == SsoRenewalStatus.FAILED and isinstance( + renewal.error, requests.RequestException + ): + raise click.ClickException( + "The SSO session has expired and Cloudsmith could not be reached. " + "Check your connection, then run 'cloudsmith auth' again." + ) from renewal.error + return None + + +def _report_active_sso_session(opts, renewal, use_stderr=False): + """Report the usable SSO session without exposing its access token.""" + expires_at = get_access_token_expiry(renewal.access_token) + data = { + "authenticated": True, + "method": "sso", + "status": renewal.status.value, + "expires_at": expires_at, + "renewal_error": str(renewal.error) if renewal.error else None, + } + if utils.maybe_print_as_json(opts, data): + return + + if renewal.status == SsoRenewalStatus.RENEWED: + click.secho("SSO session renewed.", fg="green", err=use_stderr) + elif renewal.error: + click.secho( + "The SSO session could not be renewed; the existing access token " + "remains active.", + fg="yellow", + err=use_stderr, + ) + else: + click.secho("SSO session is active.", fg="green", err=use_stderr) + if expires_at: + click.echo( + f"Access token expires at {utils.fmt_datetime(expires_at)}.", + err=use_stderr, + ) + + def _perform_saml_authentication( opts, owner, @@ -165,6 +220,14 @@ def authenticate( err=True, ) + if not force and not token and not request_api_key_flag: + session_result = _renew_existing_sso_session( + opts, profile=ctx.meta.get("profile") + ) + if session_result: + _report_active_sso_session(opts, session_result, use_stderr) + return + workspace = opts.org or click.prompt("Workspace", err=use_stderr) workspace = validators.validate_owner(ctx, None, workspace)[0] opts.org = workspace diff --git a/cloudsmith_cli/cli/tests/commands/test_auth.py b/cloudsmith_cli/cli/tests/commands/test_auth.py index 79546de5..eaede597 100644 --- a/cloudsmith_cli/cli/tests/commands/test_auth.py +++ b/cloudsmith_cli/cli/tests/commands/test_auth.py @@ -2,16 +2,32 @@ import json import webbrowser +from datetime import datetime, timezone from unittest.mock import MagicMock, patch +import jwt import pytest +import requests from ....core.api.exceptions import ApiException -from ...commands.auth import authenticate +from ....core.sso import SsoRenewalResult, SsoRenewalStatus +from ...commands.auth import _renew_existing_sso_session, authenticate from ...commands.main import main from .conftest import MockToken +@pytest.fixture(autouse=True) +def no_existing_sso_session(monkeypatch): + """Keep browser-flow tests independent of locally stored SSO sessions.""" + monkeypatch.delenv("CLOUDSMITH_WORKSPACE", raising=False) + monkeypatch.delenv("CLOUDSMITH_ORG", raising=False) + with patch( + "cloudsmith_cli.cli.commands.auth._renew_existing_sso_session", + return_value=None, + ): + yield + + @pytest.fixture def mock_saml_session(): """Mock the SAML session creation.""" @@ -163,6 +179,103 @@ def test_auth_command_passes_owner_to_webserver( call_kwargs = mock_auth_server.call_args.kwargs assert call_kwargs.get("owner") == "testorg" + def test_usable_session_avoids_workspace_and_browser(self, runner): + expires_at = datetime(2030, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + access_token = jwt.encode( + {"exp": expires_at}, + "not-used-for-verification", + algorithm="HS256", + ) + renewal = SsoRenewalResult( + status=SsoRenewalStatus.RENEWED, + access_token=access_token, + ) + with ( + patch( + "cloudsmith_cli.cli.commands.auth._renew_existing_sso_session", + return_value=renewal, + ), + patch("cloudsmith_cli.cli.commands.auth.webbrowser") as browser, + patch( + "cloudsmith_cli.cli.commands.auth.AuthenticationWebServer" + ) as auth_server, + ): + result = runner.invoke(authenticate, [], catch_exceptions=False) + + assert result.exit_code == 0 + assert result.stdout == ( + "SSO session renewed.\nAccess token expires at 2030-01-02T03:04:05Z.\n" + ) + assert "Workspace" not in result.output + browser.open.assert_not_called() + auth_server.assert_not_called() + + @pytest.mark.parametrize("output_format", ["json", "pretty_json"]) + def test_json_renewal_report_is_machine_readable(self, runner, output_format): + expires_at = datetime(2030, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + renewal = SsoRenewalResult( + status=SsoRenewalStatus.CURRENT, + access_token=jwt.encode( + {"exp": expires_at}, + "not-used-for-verification", + algorithm="HS256", + ), + ) + with patch( + "cloudsmith_cli.cli.commands.auth._renew_existing_sso_session", + return_value=renewal, + ): + result = runner.invoke( + authenticate, + ["--output-format", output_format], + catch_exceptions=False, + ) + legacy_result = ( + runner.invoke(authenticate, ["--json"], catch_exceptions=False) + if output_format == "json" + else None + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout)["data"] == { + "authenticated": True, + "expires_at": "2030-01-02T03:04:05Z", + "method": "sso", + "renewal_error": None, + "status": "current", + } + assert result.stderr == "" + if legacy_result: + assert legacy_result.stdout == "" + assert "Access token expires at 2030-01-02T03:04:05Z." in ( + legacy_result.stderr + ) + + def test_expired_session_offline_has_actionable_error(self, runner): + renewal = SsoRenewalResult( + status=SsoRenewalStatus.FAILED, + error=requests.ConnectionError("offline"), + ) + with ( + patch( + "cloudsmith_cli.cli.commands.auth.renew_sso_session", + return_value=renewal, + ), + patch( + "cloudsmith_cli.cli.commands.auth._renew_existing_sso_session", + wraps=_renew_existing_sso_session, + ), + patch( + "cloudsmith_cli.cli.commands.auth.AuthenticationWebServer" + ) as auth_server, + ): + result = runner.invoke(authenticate, []) + + assert result.exit_code == 1 + assert "Cloudsmith could not be reached" in result.output + assert "Check your connection" in result.output + auth_server.assert_not_called() + class TestBrowserFallback: """Tests for graceful handling of webbrowser.open() failures."""