Skip to content

Commit c4507b8

Browse files
feat: Add --no-keyring flag and CLOUDSMITH_NO_KEYRING env var
1 parent 377aa06 commit c4507b8

9 files changed

Lines changed: 383 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Added `--no-keyring` flag to `cloudsmith auth` which can only be used when passing `--get-token` flag in order to skip keyring checks. This will allow users to extract their Cloudsmith API key programmatically without requiring user-input. This can also be configured on environment level by setting `CLOUDSMITH_NO_KEYRING=1`.
13+
1014
## [1.12.1] - 2026-02-03
1115

1216
### Added

cloudsmith_cli/cli/commands/auth.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""CLI/Commands - Authenticate the user."""
22

3+
import os
34
import webbrowser
45

56
import click
@@ -16,7 +17,16 @@
1617
AUTH_SERVER_PORT = 12400
1718

1819

19-
def _perform_saml_authentication(opts, owner, enable_token_creation=False, json=False):
20+
def _set_no_keyring_env(ctx, param, value):
21+
"""Callback to set CLOUDSMITH_NO_KEYRING env var when flag is used."""
22+
if value:
23+
os.environ["CLOUDSMITH_NO_KEYRING"] = "1"
24+
return value
25+
26+
27+
def _perform_saml_authentication(
28+
opts, owner, enable_token_creation=False, json=False, no_keyring=False
29+
):
2030
"""Perform SAML authentication via web browser and local web server."""
2131
session = create_configured_session(opts)
2232
api_host = opts.api_config.host
@@ -40,6 +50,7 @@ def _perform_saml_authentication(opts, owner, enable_token_creation=False, json=
4050
debug=opts.debug,
4151
refresh_api_on_success=enable_token_creation,
4252
api_opts=opts.api_config,
53+
no_keyring=no_keyring,
4354
)
4455

4556
auth_server.handle_request()
@@ -81,11 +92,21 @@ def _perform_saml_authentication(opts, owner, enable_token_creation=False, json=
8192
is_flag=True,
8293
help="Output token details in json format.",
8394
)
95+
@click.option(
96+
"--no-keyring",
97+
default=False,
98+
is_flag=True,
99+
callback=_set_no_keyring_env,
100+
is_eager=True,
101+
help="Skip storing SSO tokens in system keyring. Use this in CI/CD "
102+
"environments to avoid keyring permission prompts. Note: SSO tokens "
103+
"will not persist for subsequent CLI calls.",
104+
)
84105
@decorators.common_cli_config_options
85106
@decorators.common_cli_output_options
86107
@decorators.initialise_api
87108
@click.pass_context
88-
def authenticate(ctx, opts, owner, token, force, save_config, json):
109+
def authenticate(ctx, opts, owner, token, force, save_config, json, no_keyring):
89110
"""Authenticate to Cloudsmith using the org's SAML setup."""
90111
json = json or utils.should_use_stderr(opts)
91112
# If using json output, we redirect info messages to stderr
@@ -109,7 +130,7 @@ def authenticate(ctx, opts, owner, token, force, save_config, json):
109130
context_message = "Failed to authenticate via SSO!"
110131
with handle_api_exceptions(ctx, opts=opts, context_msg=context_message):
111132
_perform_saml_authentication(
112-
opts, owner, enable_token_creation=token, json=json
133+
opts, owner, enable_token_creation=token, json=json, no_keyring=no_keyring
113134
)
114135

115136
if token:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Tests for the auth command."""
2+
3+
import os
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from ...commands.auth import authenticate
9+
10+
11+
@pytest.fixture
12+
def mock_saml_session():
13+
"""Mock the SAML session creation."""
14+
with patch(
15+
"cloudsmith_cli.cli.commands.auth.create_configured_session"
16+
) as mock_session:
17+
mock_session.return_value = MagicMock()
18+
yield mock_session
19+
20+
21+
@pytest.fixture
22+
def mock_get_idp_url():
23+
"""Mock the IDP URL retrieval."""
24+
with patch("cloudsmith_cli.cli.commands.auth.get_idp_url") as mock_url:
25+
mock_url.return_value = "https://idp.example.com/saml"
26+
yield mock_url
27+
28+
29+
@pytest.fixture
30+
def mock_webbrowser():
31+
"""Mock the webbrowser.open call."""
32+
with patch("cloudsmith_cli.cli.commands.auth.webbrowser") as mock_browser:
33+
yield mock_browser
34+
35+
36+
@pytest.fixture
37+
def mock_auth_server():
38+
"""Mock the AuthenticationWebServer."""
39+
with patch(
40+
"cloudsmith_cli.cli.commands.auth.AuthenticationWebServer"
41+
) as mock_server_class:
42+
mock_server_instance = MagicMock()
43+
mock_server_class.return_value = mock_server_instance
44+
yield mock_server_class
45+
46+
47+
class TestAuthenticateCommand:
48+
"""Tests for the authenticate command."""
49+
50+
def test_no_keyring_flag_passed_to_webserver(
51+
self,
52+
runner,
53+
mock_saml_session,
54+
mock_get_idp_url,
55+
mock_webbrowser,
56+
mock_auth_server,
57+
):
58+
"""Verify --no-keyring flag is passed to AuthenticationWebServer."""
59+
runner.invoke(
60+
authenticate,
61+
["--owner", "testorg", "--no-keyring"],
62+
catch_exceptions=False,
63+
)
64+
65+
# Verify AuthenticationWebServer was called with no_keyring=True
66+
mock_auth_server.assert_called_once()
67+
call_kwargs = mock_auth_server.call_args.kwargs
68+
assert call_kwargs.get("no_keyring") is True
69+
70+
def test_no_keyring_flag_defaults_to_false(
71+
self,
72+
runner,
73+
mock_saml_session,
74+
mock_get_idp_url,
75+
mock_webbrowser,
76+
mock_auth_server,
77+
):
78+
"""Verify no_keyring defaults to False when flag not provided."""
79+
runner.invoke(
80+
authenticate,
81+
["--owner", "testorg"],
82+
catch_exceptions=False,
83+
)
84+
85+
# Verify AuthenticationWebServer was called with no_keyring=False
86+
mock_auth_server.assert_called_once()
87+
call_kwargs = mock_auth_server.call_args.kwargs
88+
assert call_kwargs.get("no_keyring") is False
89+
90+
def test_no_keyring_flag_sets_env_var(
91+
self,
92+
runner,
93+
mock_saml_session,
94+
mock_get_idp_url,
95+
mock_webbrowser,
96+
mock_auth_server,
97+
):
98+
"""Verify --no-keyring flag sets CLOUDSMITH_NO_KEYRING env var."""
99+
# Ensure env var is not set before test
100+
env_backup = os.environ.copy()
101+
os.environ.pop("CLOUDSMITH_NO_KEYRING", None)
102+
103+
try:
104+
runner.invoke(
105+
authenticate,
106+
["--owner", "testorg", "--no-keyring"],
107+
catch_exceptions=False,
108+
)
109+
110+
# Verify environment variable was set
111+
assert os.environ.get("CLOUDSMITH_NO_KEYRING") == "1"
112+
finally:
113+
# Restore original environment
114+
os.environ.clear()
115+
os.environ.update(env_backup)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Tests for the webserver module."""
2+
3+
from unittest.mock import MagicMock, PropertyMock, patch
4+
5+
import pytest
6+
7+
from ..webserver import AuthenticationWebRequestHandler, AuthenticationWebServer
8+
9+
10+
class TestAuthenticationWebServer:
11+
"""Tests for AuthenticationWebServer."""
12+
13+
def test_no_keyring_attribute_set_from_kwargs(self):
14+
"""Verify no_keyring is set from kwargs."""
15+
with patch("socket.socket"):
16+
with patch.object(AuthenticationWebServer, "server_bind"):
17+
with patch.object(AuthenticationWebServer, "server_activate"):
18+
server = AuthenticationWebServer(
19+
("127.0.0.1", 12400),
20+
AuthenticationWebRequestHandler,
21+
bind_and_activate=False,
22+
owner="testorg",
23+
no_keyring=True,
24+
)
25+
assert server.no_keyring is True
26+
27+
def test_no_keyring_defaults_to_false(self):
28+
"""Verify no_keyring defaults to False when not provided."""
29+
with patch("socket.socket"):
30+
with patch.object(AuthenticationWebServer, "server_bind"):
31+
with patch.object(AuthenticationWebServer, "server_activate"):
32+
server = AuthenticationWebServer(
33+
("127.0.0.1", 12400),
34+
AuthenticationWebRequestHandler,
35+
bind_and_activate=False,
36+
owner="testorg",
37+
)
38+
assert server.no_keyring is False
39+
40+
41+
class TestAuthenticationWebRequestHandlerNoKeyring:
42+
"""Tests for AuthenticationWebRequestHandler with no_keyring flag."""
43+
44+
@pytest.fixture
45+
def mock_handler(self):
46+
"""Create a mock handler with controlled attributes."""
47+
with patch.object(
48+
AuthenticationWebRequestHandler, "__init__", lambda *args, **kwargs: None
49+
):
50+
handler = AuthenticationWebRequestHandler.__new__(
51+
AuthenticationWebRequestHandler
52+
)
53+
handler.server_instance = MagicMock()
54+
handler.server_instance.api_host = "https://api.cloudsmith.io"
55+
handler.refresh_api_on_success = False
56+
handler.session = MagicMock()
57+
handler.debug = False
58+
return handler
59+
60+
def test_store_sso_tokens_called_when_no_keyring_false(self, mock_handler):
61+
"""Verify store_sso_tokens is called when no_keyring is False."""
62+
mock_handler.no_keyring = False
63+
64+
with patch("cloudsmith_cli.cli.webserver.store_sso_tokens") as mock_store:
65+
with patch.object(mock_handler, "_return_success_response"):
66+
with patch.object(
67+
AuthenticationWebRequestHandler,
68+
"query_data",
69+
new_callable=PropertyMock,
70+
) as mock_query:
71+
with patch.object(
72+
AuthenticationWebRequestHandler,
73+
"api_host",
74+
new_callable=PropertyMock,
75+
) as mock_host:
76+
mock_query.return_value = {
77+
"access_token": "test_access_token",
78+
"refresh_token": "test_refresh_token",
79+
}
80+
mock_host.return_value = "https://api.cloudsmith.io"
81+
82+
mock_handler.do_GET()
83+
84+
mock_store.assert_called_once_with(
85+
"https://api.cloudsmith.io",
86+
"test_access_token",
87+
"test_refresh_token",
88+
)
89+
90+
def test_store_sso_tokens_not_called_when_no_keyring_true(self, mock_handler):
91+
"""Verify store_sso_tokens is NOT called when no_keyring is True."""
92+
mock_handler.no_keyring = True
93+
94+
with patch("cloudsmith_cli.cli.webserver.store_sso_tokens") as mock_store:
95+
with patch("click.echo") as mock_echo:
96+
with patch.object(mock_handler, "_return_success_response"):
97+
with patch.object(
98+
AuthenticationWebRequestHandler,
99+
"query_data",
100+
new_callable=PropertyMock,
101+
) as mock_query:
102+
with patch.object(
103+
AuthenticationWebRequestHandler,
104+
"api_host",
105+
new_callable=PropertyMock,
106+
) as mock_host:
107+
mock_query.return_value = {
108+
"access_token": "test_access_token",
109+
"refresh_token": "test_refresh_token",
110+
}
111+
mock_host.return_value = "https://api.cloudsmith.io"
112+
113+
mock_handler.do_GET()
114+
115+
# store_sso_tokens should NOT be called
116+
mock_store.assert_not_called()
117+
118+
# Message should be displayed to stderr
119+
mock_echo.assert_called_once_with(
120+
"SSO tokens not stored (--no-keyring enabled)",
121+
err=True,
122+
)

cloudsmith_cli/cli/webserver.py

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(
5252
self.debug = kwargs.get("debug", False)
5353
self.refresh_api_on_success = kwargs.get("refresh_api_on_success", False)
5454
self.api_opts = kwargs.get("api_opts")
55+
self.no_keyring = kwargs.get("no_keyring", False)
5556
self.exception = None
5657

5758
super().__init__(
@@ -90,6 +91,7 @@ def finish_request(self, request, client_address):
9091
session=self.session,
9192
refresh_api_on_success=self.refresh_api_on_success,
9293
server_instance=self,
94+
no_keyring=self.no_keyring,
9395
)
9496

9597
def _handle_request_noblock(self):
@@ -133,6 +135,7 @@ def __init__(self, request, client_address, server, **kwargs):
133135
self.session = kwargs.get("session")
134136
self.refresh_api_on_success = kwargs.get("refresh_api_on_success", False)
135137
self.server_instance = kwargs.get("server_instance")
138+
self.no_keyring = kwargs.get("no_keyring", False)
136139

137140
super().__init__(request, client_address, server)
138141

@@ -200,11 +203,17 @@ def do_GET(self):
200203

201204
try:
202205
if access_token:
203-
store_sso_tokens(
204-
self.api_host,
205-
access_token,
206-
refresh_token,
207-
)
206+
if self.no_keyring:
207+
click.echo(
208+
"SSO tokens not stored (--no-keyring enabled)",
209+
err=True,
210+
)
211+
else:
212+
store_sso_tokens(
213+
self.api_host,
214+
access_token,
215+
refresh_token,
216+
)
208217

209218
if self.refresh_api_on_success and self.server_instance:
210219
self.server_instance.refresh_api_config_after_auth()
@@ -220,11 +229,17 @@ def do_GET(self):
220229
access_token, refresh_token = exchange_2fa_token(
221230
self.api_host, two_factor_token, totp_token, session=self.session
222231
)
223-
store_sso_tokens(
224-
self.api_host,
225-
access_token,
226-
refresh_token,
227-
)
232+
if self.no_keyring:
233+
click.echo(
234+
"SSO tokens not stored (--no-keyring enabled)",
235+
err=True,
236+
)
237+
else:
238+
store_sso_tokens(
239+
self.api_host,
240+
access_token,
241+
refresh_token,
242+
)
228243

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

0 commit comments

Comments
 (0)