diff --git a/CHANGELOG.md b/CHANGELOG.md index e7548ae4..387fb3e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith domains list` keeps version 1 of its JSON document. The API does not give `primary` and `created_at`, so these fields are now always `null`. The hosts are in the preference order of the API. `domain_type` can also be `cloudsmith_api`. - Without a Workspace, `cloudsmith domains list` lists the domains that earlier runs cached. If no domains are cached, it gets the domains of a Workspace that the credential is a member of. Without a credential, the list is empty. - `cloudsmith credential-helper install` now configures the Cloudsmith-owned hosts that the API lists for the Workspace. It uses its built-in default host, such as `docker.cloudsmith.io`, only when it cannot get the domains from the API. +- The hint shown for a `401 - Unauthorized` error when no API key or SAML session is set no longer suggests `cloudsmith token`. It now suggests `CLOUDSMITH_API_KEY` or `cloudsmith auth`. + +### Deprecated + +- `cloudsmith login` and its `cloudsmith token` alias are deprecated, and will stop working on November 20, 2026, when Cloudsmith disables username and password login. See the [Cloudsmith changelog](https://cloudsmith.com/changelog). Until then, both commands work as before, but they print a deprecation warning to stderr before they prompt for credentials. Once the server disables password login (`410 Gone`, or `404` after the endpoint is deleted), they fail with a message that explains how to get an API key instead. Other errors, such as outages or wrong credentials, are reported as before. To replace them, set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app (your user menu, then **Personal API Keys**), or run `cloudsmith auth --workspace ` to log in with SAML. See [Getting your API key](https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key). ### Removed diff --git a/README.md b/README.md index 4f2f2cd2..6fd7a2bc 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The CLI currently supports the following commands (and sub-commands): - `entitlements`|`ents`: List entitlements for a repository. - `packages`: List packages for a repository. (Aliases `repos list`) - `repos`: List repositories for a namespace (owner). -- `login`|`token`: Retrieve your API authentication token/key via login. +- `login`|`token`: [DEPRECATED] Retrieve your API authentication token/key via login. - `logout`: Clear stored authentication credentials and SSO tokens (Keyring, API key from credential file and emit warning when `$CLOUDSMITH_API_KEY` is still set). - `metadata`: Manage arbitrary JSON metadata (SBOM, BuildInfo, custom) attached to a package. - `add`: Attach a new metadata entry to a package. @@ -363,29 +363,15 @@ cloudsmith auth --owner example --no-browser #### Getting Your API Key -You can retrieve your API key using the `cloudsmith login` command: +Get your API key from the Cloudsmith web app: click your user icon in the top right corner, then click **Personal API Keys**. See [API Key](https://docs.cloudsmith.com/accounts-and-teams/api-key) for more information. If you authenticated with `cloudsmith auth`, you can also use `cloudsmith tokens create` to create an API key. -``` -cloudsmith login -Login: you@example.com -Password: -Repeat for confirmation: -``` - -*Note:* Please ensure you use your email for the 'Login' prompt and not your user slug/identifier. - -The resulting output looks something like: - -``` -Retrieving API token for 'you@example.com' ... OK -Your API token is: 1234567890abcdef1234567890abcdef -``` - -Once you have your API key you can then put this into your `credentials.ini`, use it as an environment variable `export CLOUDSMITH_API_KEY=your_key_here` or pass it to the CLI using the `-k your_key_here` flag. +Give your API key to the CLI in one of these ways: -For convenience the CLI will ask you if you want to install the default configuration files, complete with your API key, if they don't already exist. Say 'y' or 'yes' to create the configuration files. +- Set the `CLOUDSMITH_API_KEY` environment variable: `export CLOUDSMITH_API_KEY=your_key_here`. +- Put the key into your `credentials.ini` file. +- Pass the key to the CLI with the `-k your_key_here` flag. -If the configuration files already exist, you'll have to manually put the API key into the configuration files, but the CLI will print out their locations. +*Note:* The `cloudsmith login` command and its `cloudsmith token` alias are deprecated. They retrieve your API key with your email address and password, and they will stop working on November 20, 2026, when Cloudsmith disables username and password login. See the [Cloudsmith changelog](https://cloudsmith.com/changelog). After that, both commands fail with a message that explains how to get an API key instead. ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index a2f73399..245f47a4 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -21,7 +21,58 @@ def validate_login(ctx, param, value): return value -@main.command(aliases=["token"]) +#: Docs section on getting an API key and logging in with SAML instead. +API_KEY_DOCS_URL = ( + "https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key" +) + +DEPRECATION_WARNING = ( + "Warning: `cloudsmith {name}` is deprecated and will stop working on " + "November 20, 2026, when Cloudsmith disables username and password login. " + "See https://cloudsmith.com/changelog\n" + "Use an API key or SAML instead: set `CLOUDSMITH_API_KEY` to an API key from " + "the Cloudsmith web app (your user menu, then Personal API Keys), or run " + "`cloudsmith auth --workspace ` to log in with SAML.\n" + f"More: {API_KEY_DOCS_URL}" +) + + +REMOVED_MESSAGE = ( + "Could not retrieve API key: username and password login is disabled.\n" + "Fix: set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app " + "(your user menu, then Personal API Keys), or run " + "`cloudsmith auth --workspace ` to log in with SAML.\n" + f"More: {API_KEY_DOCS_URL}" +) + +#: Statuses the token endpoint returns once the server has disabled password +#: login (410) or deleted the route entirely (404). +REMOVED_STATUSES = (404, 410) + + +def summarise_removed_login(exc, detail, fields): + """Replace the raw error with a clear notice once the endpoint is disabled.""" + # pylint: disable=unused-argument + if exc.status in REMOVED_STATUSES: + return REMOVED_MESSAGE + return None + + +class DeprecatedLoginCommand(click.Command): + """Command that prints the deprecation warning before any prompt.""" + + def parse_args(self, ctx, args): + # Click prompts for --login and --password while parsing, so the + # warning must be printed here rather than in the command body. + # Shell completion also parses (resiliently) and must stay quiet. + if not ctx.resilient_parsing: + click.secho( + DEPRECATION_WARNING.format(name=ctx.info_name), fg="yellow", err=True + ) + return super().parse_args(ctx, args) + + +@main.command(cls=DeprecatedLoginCommand, aliases=["token"]) @click.option( "-l", "--login", @@ -36,18 +87,32 @@ def validate_login(ctx, param, value): @decorators.initialise_api @click.pass_context def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name - """Retrieve your API authentication token/key via login.""" + """[DEPRECATED] Retrieve your API key with your email address and password. + + This command will stop working on November 20, 2026, when Cloudsmith + disables username and password login. See https://cloudsmith.com/changelog + + Set `CLOUDSMITH_API_KEY` to an API key from the Cloudsmith web app instead, + or run `cloudsmith auth --workspace ` to log in with SAML. + + More: https://docs.cloudsmith.com/developer-tools/cli#getting-your-api-key + """ use_stderr = utils.should_use_stderr(opts) click.echo( - f"Retrieving API token for {click.style(login, bold=True)} ... ", + f"Retrieving API key for {click.style(login, bold=True)} ... ", nl=False, err=use_stderr, ) - context_msg = "Failed to retrieve the API token!" + context_msg = "Could not retrieve API key" try: with ( - handle_api_exceptions(ctx, opts=opts, context_msg=context_msg), + handle_api_exceptions( + ctx, + opts=opts, + context_msg=context_msg, + summarise_error=summarise_removed_login, + ), maybe_spinner(opts), ): api_key = get_user_token(login=login, password=password) @@ -66,7 +131,12 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name try: with ( - handle_api_exceptions(ctx, opts=opts, context_msg=context_msg), + handle_api_exceptions( + ctx, + opts=opts, + context_msg=context_msg, + summarise_error=summarise_removed_login, + ), maybe_spinner(opts), ): api_key = get_user_token( diff --git a/cloudsmith_cli/cli/exceptions.py b/cloudsmith_cli/cli/exceptions.py index de7f01f4..a3519b21 100644 --- a/cloudsmith_cli/cli/exceptions.py +++ b/cloudsmith_cli/cli/exceptions.py @@ -201,7 +201,7 @@ def get_401_error_hint(ctx, opts, exc): credential = getattr(opts, "credential", None) if credential and credential.auth_type == "bearer": - return "Since you have an SSO access token set, this probably means that it has expired. Try getting a new token with 'cloudsmith auth', then try again." + return "Since you have a SAML access token set, this probably means that it has expired. Try getting a new token with 'cloudsmith auth', then try again." if credential: return ( @@ -210,8 +210,8 @@ def get_401_error_hint(ctx, opts, exc): "try again." ) - if ctx.info_name == "token": - # This is already the token command + if ctx.info_name in ("login", "token"): + # This is already the (deprecated) login command return ( "The login failed - Either your email address and/or " "your password was incorrect. Please check them and " @@ -219,15 +219,19 @@ def get_401_error_hint(ctx, opts, exc): ) return ( - "You don't have an API key or access token set, but it seems this action " - "requires authentication - Try getting your API key via " - "'cloudsmith token', or access token via 'cloudsmith auth', then try again." + "This command requires authentication, and no API key or SAML session was " + "found. Set `CLOUDSMITH_API_KEY` to your API key, or run " + "`cloudsmith auth --workspace ` to log in with SAML." ) def get_404_error_hint(ctx, opts, exc): """Get the hint for a 404/NotFound error.""" # pylint: disable=unused-argument + if ctx.info_name in ("login", "token"): + # The token endpoint itself is gone, not a user/org + return None + # pylint: disable=fixme # TODO(ls): Expand this to be contextual (we could look at the # arguments for the command). diff --git a/cloudsmith_cli/cli/tests/commands/test_login.py b/cloudsmith_cli/cli/tests/commands/test_login.py index 724fa002..b117e438 100644 --- a/cloudsmith_cli/cli/tests/commands/test_login.py +++ b/cloudsmith_cli/cli/tests/commands/test_login.py @@ -1,6 +1,12 @@ +import json +from contextlib import ExitStack +from unittest.mock import Mock, patch + import pytest -from ...commands.login import login +from ....core.api.exceptions import ApiException, TwoFactorRequiredException +from ...commands.login import API_KEY_DOCS_URL, REMOVED_MESSAGE, login +from ...commands.main import main @pytest.mark.usefixtures("set_api_host_env_var") @@ -29,3 +35,207 @@ def test_login_via_args(self, runner, username, password, api_key): assert not result.exception assert result.exit_code == 0 assert expected in result.stdout + + +def invoke_main_login( + runner, + config_dir, + name, + *extra_args, + user_input=None, + get_user_token=None, +): + """Run `cloudsmith ` through the registered command tree. + + The config and credentials paths point at an empty directory so a real + config.ini or credentials.ini on the machine cannot change the result. + ``get_user_token`` replaces the API call (default: return a fake key). + """ + args = [ + name, + "--config-file", + str(config_dir), + "--credentials-file", + str(config_dir), + "--api-host", + "https://api.example.invalid", + *extra_args, + ] + with ExitStack() as stack: + stack.enter_context( + patch( + "cloudsmith_cli.cli.commands.login.get_user_token", + get_user_token or Mock(return_value="fake-api-key"), + ) + ) + stack.enter_context( + patch( + "cloudsmith_cli.cli.commands.login.create_config_files", + return_value=(False, False), + ) + ) + stack.enter_context( + patch("cloudsmith_cli.cli.commands.login.new_config_messaging") + ) + return runner.invoke(main, args, input=user_input) + + +@pytest.mark.parametrize("name", ["login", "token"]) +class TestLoginDeprecation: + """Login still works, but every invocation warns that it is going away.""" + + def test_warns_on_stderr_and_still_returns_the_key(self, runner, tmp_path, name): + result = invoke_main_login( + runner, tmp_path, name, "-l", "you@example.com", "-p", "secret" + ) + + assert result.exit_code == 0 + assert f"Warning: `cloudsmith {name}` is deprecated" in result.stderr + assert "Personal API Keys" in result.stderr + assert "cloudsmith auth" in result.stderr + assert "Use an API key or SAML instead" in result.stderr + assert "log in with SAML." in result.stderr + assert "sso" not in result.stderr.lower() + assert "will stop working on November 20, 2026" in result.stderr + assert "https://cloudsmith.com/changelog" in result.stderr + assert f"More: {API_KEY_DOCS_URL}" in result.stderr + assert "is deprecated" not in result.stdout + assert "Your API key/token is: fake-api-key" in result.stdout + + def test_warns_before_prompting_for_credentials(self, runner, tmp_path, name): + result = invoke_main_login( + runner, + tmp_path, + name, + user_input="you@example.com\nsecret\nsecret\n", + ) + + assert result.exit_code == 0 + assert result.output.index("is deprecated") < result.output.index("Login:") + + def test_json_output_stays_parseable(self, runner, tmp_path, name): + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + "--output-format", + "json", + ) + + assert result.exit_code == 0 + assert "is deprecated" in result.stderr + assert json.loads(result.stdout)["data"]["token"] == "fake-api-key" + + def test_help_marks_the_command_deprecated(self, runner, tmp_path, name): + result = invoke_main_login(runner, tmp_path, name, "--help") + + assert result.exit_code == 0 + assert "[DEPRECATED]" in result.stdout + assert API_KEY_DOCS_URL in result.stdout + assert "log in with SAML." in result.stdout + assert "sso" not in result.stdout.lower() + + +def test_shell_completion_parsing_does_not_warn(capsys): + """Tab completion parses resiliently and must not print the warning.""" + login.make_context("login", ["-l", "you@example.com"], resilient_parsing=True) + + captured = capsys.readouterr() + assert "is deprecated" not in captured.err + assert "is deprecated" not in captured.out + + +@pytest.mark.parametrize("name", ["login", "token"]) +@pytest.mark.parametrize("status", [404, 410]) +class TestLoginSwitchedOff: + """Once the server switches the endpoint off, say so instead of a raw error.""" + + @staticmethod + def switched_off(status): + return Mock(side_effect=ApiException(status, detail="Gone.")) + + def test_reports_removal(self, runner, tmp_path, name, status): + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + get_user_token=self.switched_off(status), + ) + + assert result.return_value == status + assert REMOVED_MESSAGE in result.output + assert "log in with SAML." in result.output + assert "sso" not in result.output.lower() + assert f"More: {API_KEY_DOCS_URL}" in result.output + assert f"status: {status}" not in result.output + assert "usually means the user/org is wrong" not in result.output + + def test_json_reports_removal(self, runner, tmp_path, name, status): + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + "--output-format", + "json", + get_user_token=self.switched_off(status), + ) + + error = json.loads(result.stdout) + assert result.return_value == status + assert error["detail"] == REMOVED_MESSAGE + assert error["meta"]["code"] == status + assert error["help"]["hint"] is None + + def test_reports_removal_after_two_factor(self, runner, tmp_path, name, status): + get_user_token = Mock( + side_effect=[ + TwoFactorRequiredException("2fa-token"), + ApiException(status, detail="Gone."), + ] + ) + + result = invoke_main_login( + runner, + tmp_path, + name, + "-l", + "you@example.com", + "-p", + "secret", + user_input="123456\n", + get_user_token=get_user_token, + ) + + assert result.return_value == status + assert REMOVED_MESSAGE in result.output + + +@pytest.mark.parametrize("status", [401, 500, 503]) +def test_other_errors_keep_the_standard_rendering(runner, tmp_path, status): + """An outage or bad credentials must not be reported as a removal.""" + result = invoke_main_login( + runner, + tmp_path, + "login", + "-l", + "you@example.com", + "-p", + "secret", + get_user_token=Mock(side_effect=ApiException(status, detail="Nope.")), + ) + + assert result.return_value == status + assert REMOVED_MESSAGE not in result.output + assert f"status: {status}" in result.output diff --git a/cloudsmith_cli/cli/tests/test_exceptions.py b/cloudsmith_cli/cli/tests/test_exceptions.py index 67f143d9..da3153b1 100644 --- a/cloudsmith_cli/cli/tests/test_exceptions.py +++ b/cloudsmith_cli/cli/tests/test_exceptions.py @@ -3,6 +3,8 @@ import json from unittest.mock import Mock, patch +import pytest + from cloudsmith_cli.cli.commands.main import main from cloudsmith_cli.cli.exceptions import get_401_error_hint, handle_api_exceptions from cloudsmith_cli.core.api.exceptions import ApiException @@ -33,10 +35,14 @@ class TestGet401ErrorHint: def test_bearer_credential_suggests_reauthenticating(self): credential = CredentialResult( - api_key="sso-token", source_name="keyring", auth_type="bearer" + api_key="saml-token", source_name="keyring", auth_type="bearer" ) - assert "cloudsmith auth" in hint_for(credential) + hint = hint_for(credential) + + assert "cloudsmith auth" in hint + assert "SAML access token" in hint + assert "sso" not in hint.lower() def test_api_key_credential_does_not_assert_a_permissions_problem(self): """A 401 alone cannot establish a specific permissions problem.""" @@ -45,10 +51,18 @@ def test_api_key_credential_does_not_assert_a_permissions_problem(self): assert hint_for(credential) == API_KEY_HINT def test_no_credential_suggests_authenticating(self): - assert "cloudsmith token" in hint_for(None) - - def test_no_credential_on_token_command_reports_a_failed_login(self): - assert "login failed" in hint_for(None, info_name="token") + hint = hint_for(None) + + assert "CLOUDSMITH_API_KEY" in hint + assert "cloudsmith auth" in hint + assert "cloudsmith token" not in hint + assert "SAML session" in hint + assert "log in with SAML." in hint + assert "sso" not in hint.lower() + + @pytest.mark.parametrize("info_name", ["login", "token"]) + def test_no_credential_on_login_command_reports_a_failed_login(self, info_name): + assert "login failed" in hint_for(None, info_name=info_name) def invoke_credentialed_401(runner, config_dir, output_format="pretty"):