From de340398380518bb31a48f2fbac708835ce1b957 Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Tue, 16 Dec 2025 12:59:26 -0500 Subject: [PATCH 1/4] Add a prompt to 'aws login' to warn users when updating a profile with existing credentials --- .../next-release/enhancement-login-27668.json | 5 + awscli/customizations/login/login.py | 48 +++++++++ tests/functional/login/test_login.py | 101 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 .changes/next-release/enhancement-login-27668.json diff --git a/.changes/next-release/enhancement-login-27668.json b/.changes/next-release/enhancement-login-27668.json new file mode 100644 index 000000000000..74f7d669f32b --- /dev/null +++ b/.changes/next-release/enhancement-login-27668.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "``login``", + "description": "Add a prompt to ``aws login`` to warn users when updating a profile with existing credentials." +} diff --git a/awscli/customizations/login/login.py b/awscli/customizations/login/login.py index 4959b6d9c104..8e8cb8431ed0 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -96,6 +96,11 @@ def _run_main(self, parsed_args, parsed_globals): if profile_name not in self._session.available_profiles: self._session._profile_map[profile_name] = {} + if not self.accept_existing_credentials_warning_if_needed( + profile_name + ): + return + config = botocore.config.Config( region_name=region, signature_version=botocore.UNSIGNED, @@ -177,6 +182,49 @@ def accept_change_to_existing_profile_if_needed( else: uni_print('Invalid response. Please enter "y" or "n"') + def accept_existing_credentials_warning_if_needed(self, profile_name): + """ + Checks if the specified profile is already configured with a + different style of credentials. If so, warn the user and prompt them to + continue. + """ + config = self._session.full_config['profiles'].get(profile_name, {}) + existing_credentials_style = None + + if 'web_identity_token_file' in config: + existing_credentials_style = 'Web Identity' + elif 'sso_role_name' in config or 'sso_account_id' in config: + existing_credentials_style = 'SSO' + elif 'aws_access_key_id' in config: + existing_credentials_style = 'Access Key' + elif 'role_arn' in config: + existing_credentials_style = 'Assume Role' + elif 'credential_process' in config: + existing_credentials_style = 'Credential Process' + + if not existing_credentials_style: + return True + + while True: + response = compat_input( + f'\nWarning: Profile \'{profile_name}\' is already configured ' + f'with {existing_credentials_style} credentials. ' + f'If you continue to log in, the CLI and other tools may ' + f'continue to use the existing credentials instead.\n\n' + f'You may run \'aws login --profile new-profile-name\' to ' + f'create a new profile, or else you may manually remove the ' + f'existing credentials from \'{profile_name}\'.\n\n' + f'Do you want to continue adding login credentials ' + f'to \'{profile_name}\'? (y/n): ' + ) + + if response.lower() in ('y', 'yes'): + return True + elif response.lower() in ('n', 'no'): + return False + else: + uni_print('Invalid response. Please enter "y" or "n"') + @staticmethod def resolve_sign_in_type(parsed_args): if parsed_args.remote: diff --git a/tests/functional/login/test_login.py b/tests/functional/login/test_login.py index 29c870b72e51..235ef0c5da9c 100644 --- a/tests/functional/login/test_login.py +++ b/tests/functional/login/test_login.py @@ -230,3 +230,104 @@ def test_new_profile_without_region( }, 'configfile', ) + + +@pytest.mark.parametrize( + 'profile_config,expect_prompt_to_be_called', + [ + pytest.param({}, False, id="Empty profile"), + pytest.param( + {'login_session': 'arn:aws:iam::0123456789012:user/Admin'}, + False, + id="Existing login profile", + ), + pytest.param( + {'web_identity_token_file': '/path'}, + True, + id="Web Identity Token profile", + ), + pytest.param({'sso_role_name': 'role'}, True, id="SSO profile"), + pytest.param( + {'aws_access_key_id': 'AKIAIOSFODNN7EXAMPLE'}, + True, + id="IAM access key profile", + ), + pytest.param( + {'role_arn': 'arn:aws:iam::123456789012:role/MyRole'}, + True, + id="Assume role profile", + ), + pytest.param( + {'credential_process': '/path/to/credential/process'}, + True, + id="Credential process profile", + ), + ], +) +@mock.patch('awscli.customizations.login.login.compat_input', return_value='y') +@mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') +@mock.patch( + 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' +) +def test_accept_change_to_existing_profile_if_needed( + mock_token_fetcher, + mock_base_sign_in_uri, + mock_input, + mock_login_command, + mock_session, + profile_config, + expect_prompt_to_be_called, +): + mock_base_sign_in_uri.return_value = 'https://foo' + mock_token_fetcher.return_value = ( + { + 'accessToken': 'access_token', + 'idToken': SAMPLE_ID_TOKEN, + 'expiresIn': 3600, + }, + 'arn:aws:iam::0123456789012:user/Admin', + ) + mock_session.full_config = {'profiles': {'profile-name': profile_config}} + + mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) + mock_token_fetcher.assert_called_once() + + assert mock_input.called == expect_prompt_to_be_called + + +@mock.patch('awscli.customizations.login.login.compat_input', return_value='n') +@mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') +@mock.patch( + 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' +) +def test_decline_change_to_existing_profile_does_not_update( + mock_token_fetcher, + mock_base_sign_in_uri, + mock_input, + mock_login_command, + mock_session, + mock_config_file_writer, + mock_token_loader, +): + mock_base_sign_in_uri.return_value = 'https://foo' + mock_token_fetcher.return_value = ( + { + 'accessToken': 'access_token', + 'idToken': SAMPLE_ID_TOKEN, + 'expiresIn': 3600, + }, + 'arn:aws:iam::0123456789012:user/Admin', + ) + mock_session.full_config = { + 'profiles': { + 'profile-name': {'aws_access_key_id': 'AKIAIOSFODNN7EXAMPLE'} + } + } + + mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) + + # Because we mocked 'n' to compat_input above, we don't expect the command + # to have finished when the user declines the existing credential prompt + mock_input.assert_called_once() + mock_token_loader.save_token.assert_not_called() + mock_config_file_writer.update_config.assert_not_called() From 3571218a0554ae5c715694aa214aad3f9606e0e9 Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Mon, 5 Jan 2026 16:11:31 -0500 Subject: [PATCH 2/4] Change from prompt to error --- awscli/customizations/login/login.py | 40 ++++++++------------- tests/functional/login/test_login.py | 53 +++++----------------------- 2 files changed, 22 insertions(+), 71 deletions(-) diff --git a/awscli/customizations/login/login.py b/awscli/customizations/login/login.py index 8e8cb8431ed0..e635179acfd6 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -96,9 +96,9 @@ def _run_main(self, parsed_args, parsed_globals): if profile_name not in self._session.available_profiles: self._session._profile_map[profile_name] = {} - if not self.accept_existing_credentials_warning_if_needed( - profile_name - ): + # Abort if the profile is already configured with a different style + # of credentials, since they'd still have precedence over login + if self.does_profile_have_non_login_credentials(profile_name): return config = botocore.config.Config( @@ -182,11 +182,10 @@ def accept_change_to_existing_profile_if_needed( else: uni_print('Invalid response. Please enter "y" or "n"') - def accept_existing_credentials_warning_if_needed(self, profile_name): + def does_profile_have_non_login_credentials(self, profile_name): """ - Checks if the specified profile is already configured with a - different style of credentials. If so, warn the user and prompt them to - continue. + Checks if the specified profile is already configured + with a different style of credentials. """ config = self._session.full_config['profiles'].get(profile_name, {}) existing_credentials_style = None @@ -202,28 +201,17 @@ def accept_existing_credentials_warning_if_needed(self, profile_name): elif 'credential_process' in config: existing_credentials_style = 'Credential Process' - if not existing_credentials_style: - return True - - while True: - response = compat_input( - f'\nWarning: Profile \'{profile_name}\' is already configured ' - f'with {existing_credentials_style} credentials. ' - f'If you continue to log in, the CLI and other tools may ' - f'continue to use the existing credentials instead.\n\n' + if existing_credentials_style: + uni_print( + f'\nError: Profile \'{profile_name}\' is already configured ' + f'with {existing_credentials_style} credentials.\n\n' f'You may run \'aws login --profile new-profile-name\' to ' - f'create a new profile, or else you may manually remove the ' - f'existing credentials from \'{profile_name}\'.\n\n' - f'Do you want to continue adding login credentials ' - f'to \'{profile_name}\'? (y/n): ' + f'create a new profile, or you must first manually ' + f'remove the existing credentials from \'{profile_name}\'.\n' ) + return True - if response.lower() in ('y', 'yes'): - return True - elif response.lower() in ('n', 'no'): - return False - else: - uni_print('Invalid response. Please enter "y" or "n"') + return False @staticmethod def resolve_sign_in_type(parsed_args): diff --git a/tests/functional/login/test_login.py b/tests/functional/login/test_login.py index 235ef0c5da9c..d14e5cebb008 100644 --- a/tests/functional/login/test_login.py +++ b/tests/functional/login/test_login.py @@ -233,7 +233,7 @@ def test_new_profile_without_region( @pytest.mark.parametrize( - 'profile_config,expect_prompt_to_be_called', + 'profile_config,expected_to_abort', [ pytest.param({}, False, id="Empty profile"), pytest.param( @@ -264,19 +264,18 @@ def test_new_profile_without_region( ), ], ) -@mock.patch('awscli.customizations.login.login.compat_input', return_value='y') @mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') @mock.patch( 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' ) -def test_accept_change_to_existing_profile_if_needed( +def test_abort_if_profile_has_existing_credentials( mock_token_fetcher, mock_base_sign_in_uri, - mock_input, mock_login_command, mock_session, + mock_token_loader, profile_config, - expect_prompt_to_be_called, + expected_to_abort, ): mock_base_sign_in_uri.return_value = 'https://foo' mock_token_fetcher.return_value = ( @@ -289,45 +288,9 @@ def test_accept_change_to_existing_profile_if_needed( ) mock_session.full_config = {'profiles': {'profile-name': profile_config}} - mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) - mock_token_fetcher.assert_called_once() - - assert mock_input.called == expect_prompt_to_be_called - - -@mock.patch('awscli.customizations.login.login.compat_input', return_value='n') -@mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') -@mock.patch( - 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' -) -def test_decline_change_to_existing_profile_does_not_update( - mock_token_fetcher, - mock_base_sign_in_uri, - mock_input, - mock_login_command, - mock_session, - mock_config_file_writer, - mock_token_loader, -): - mock_base_sign_in_uri.return_value = 'https://foo' - mock_token_fetcher.return_value = ( - { - 'accessToken': 'access_token', - 'idToken': SAMPLE_ID_TOKEN, - 'expiresIn': 3600, - }, - 'arn:aws:iam::0123456789012:user/Admin', - ) - mock_session.full_config = { - 'profiles': { - 'profile-name': {'aws_access_key_id': 'AKIAIOSFODNN7EXAMPLE'} - } - } - mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) - # Because we mocked 'n' to compat_input above, we don't expect the command - # to have finished when the user declines the existing credential prompt - mock_input.assert_called_once() - mock_token_loader.save_token.assert_not_called() - mock_config_file_writer.update_config.assert_not_called() + if expected_to_abort: + mock_token_fetcher.assert_not_called() + else: + mock_token_fetcher.assert_called_once() From 8440aaf62b615adde88a39c095234fc916ece5cd Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Mon, 5 Jan 2026 16:32:05 -0500 Subject: [PATCH 3/4] update changelog entry --- .changes/next-release/enhancement-login-27668.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/next-release/enhancement-login-27668.json b/.changes/next-release/enhancement-login-27668.json index 74f7d669f32b..d34fab79d458 100644 --- a/.changes/next-release/enhancement-login-27668.json +++ b/.changes/next-release/enhancement-login-27668.json @@ -1,5 +1,5 @@ { "type": "enhancement", "category": "``login``", - "description": "Add a prompt to ``aws login`` to warn users when updating a profile with existing credentials." + "description": "Prevent ``aws login`` from updating a profile with a different style of existing credentials." } From c45e10e7d8440a3e10c6fe010663d8279c9ea34a Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Wed, 7 Jan 2026 16:53:53 -0500 Subject: [PATCH 4/4] Switch to raising an exception --- awscli/customizations/login/login.py | 20 ++++++++++---------- tests/functional/login/test_login.py | 8 +++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/awscli/customizations/login/login.py b/awscli/customizations/login/login.py index e635179acfd6..f102ab2ae795 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -20,6 +20,7 @@ RequiredInputValidator, ) from awscli.customizations.configure.writer import ConfigFileWriter +from awscli.customizations.exceptions import ConfigurationError from awscli.customizations.login.utils import ( CrossDeviceLoginTokenFetcher, LoginType, @@ -98,8 +99,7 @@ def _run_main(self, parsed_args, parsed_globals): # Abort if the profile is already configured with a different style # of credentials, since they'd still have precedence over login - if self.does_profile_have_non_login_credentials(profile_name): - return + self.ensure_profile_does_not_have_existing_credentials(profile_name) config = botocore.config.Config( region_name=region, @@ -182,10 +182,10 @@ def accept_change_to_existing_profile_if_needed( else: uni_print('Invalid response. Please enter "y" or "n"') - def does_profile_have_non_login_credentials(self, profile_name): + def ensure_profile_does_not_have_existing_credentials(self, profile_name): """ - Checks if the specified profile is already configured - with a different style of credentials. + Raises an error if the specified profile is already + configured with a different style of credentials. """ config = self._session.full_config['profiles'].get(profile_name, {}) existing_credentials_style = None @@ -202,14 +202,14 @@ def does_profile_have_non_login_credentials(self, profile_name): existing_credentials_style = 'Credential Process' if existing_credentials_style: - uni_print( - f'\nError: Profile \'{profile_name}\' is already configured ' + raise ConfigurationError( + f'Profile \'{profile_name}\' is already configured ' f'with {existing_credentials_style} credentials.\n\n' f'You may run \'aws login --profile new-profile-name\' to ' - f'create a new profile, or you must first manually ' - f'remove the existing credentials from \'{profile_name}\'.\n' + f'create a new profile with the specified name. Otherwise you ' + f'must first manually remove the existing credentials ' + f'from \'{profile_name}\'.\n' ) - return True return False diff --git a/tests/functional/login/test_login.py b/tests/functional/login/test_login.py index d14e5cebb008..a89fa0a31670 100644 --- a/tests/functional/login/test_login.py +++ b/tests/functional/login/test_login.py @@ -6,6 +6,7 @@ import pytest +from awscli.customizations.exceptions import ConfigurationError from awscli.customizations.login.login import LoginCommand DEFAULT_ARGS = Namespace(remote=False) @@ -288,9 +289,10 @@ def test_abort_if_profile_has_existing_credentials( ) mock_session.full_config = {'profiles': {'profile-name': profile_config}} - mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) - if expected_to_abort: - mock_token_fetcher.assert_not_called() + with pytest.raises(ConfigurationError): + mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) + mock_token_fetcher.assert_not_called() else: + mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) mock_token_fetcher.assert_called_once()