From aedf5ca482190d10e2c4e74d4ea857a9295fac6e Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Sun, 13 Sep 2026 22:29:17 -0400 Subject: [PATCH 1/2] Add configurable redirect port for SSO and login OAuth flows --- .../next-release/feature-login-34536.json | 5 ++ .changes/next-release/feature-sso-34535.json | 5 ++ .../customizations/configure/sso_commands.py | 3 + awscli/customizations/login/login.py | 10 +++- awscli/customizations/sso/login.py | 3 + awscli/customizations/sso/utils.py | 41 ++++++++++++-- tests/functional/login/test_login.py | 51 ++++++++++++++++- tests/functional/sso/test_login.py | 23 ++++++++ .../unit/customizations/configure/test_sso.py | 41 ++++++++++++++ tests/unit/customizations/sso/test_utils.py | 55 ++++++++++++++++++- 10 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 .changes/next-release/feature-login-34536.json create mode 100644 .changes/next-release/feature-sso-34535.json diff --git a/.changes/next-release/feature-login-34536.json b/.changes/next-release/feature-login-34536.json new file mode 100644 index 000000000000..6985312defaf --- /dev/null +++ b/.changes/next-release/feature-login-34536.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "``login``", + "description": "Add a ``--redirect-port`` option to set the localhost port that the callback server listens on, instead of choosing an available port at random." +} diff --git a/.changes/next-release/feature-sso-34535.json b/.changes/next-release/feature-sso-34535.json new file mode 100644 index 000000000000..370d489259f1 --- /dev/null +++ b/.changes/next-release/feature-sso-34535.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "``sso``", + "description": "Add a ``--redirect-port`` option to ``aws sso login`` and ``aws configure sso`` to set the localhost port that the callback server listens on, instead of choosing an available port at random." +} diff --git a/awscli/customizations/configure/sso_commands.py b/awscli/customizations/configure/sso_commands.py index 3c7d0054127c..7576a605f4e4 100644 --- a/awscli/customizations/configure/sso_commands.py +++ b/awscli/customizations/configure/sso_commands.py @@ -49,6 +49,7 @@ BaseSSOCommand, PrintOnlyHandler, do_sso_login, + validate_redirect_port, ) from awscli.customizations.utils import uni_print from awscli.formatter import CLI_OUTPUT_FORMATS @@ -315,6 +316,7 @@ def _unset_session_profile(self): def _run_main(self, parsed_args, parsed_globals): super()._run_main(parsed_args, parsed_globals) + validate_redirect_port(parsed_args.redirect_port) self._unset_session_profile() on_pending_authorization = None if parsed_args.no_browser: @@ -331,6 +333,7 @@ def _run_main(self, parsed_args, parsed_globals): token_cache=self._sso_token_cache, on_pending_authorization=on_pending_authorization, use_device_code=parsed_args.use_device_code, + redirect_port=parsed_args.redirect_port, **sso_registration_args, ) diff --git a/awscli/customizations/login/login.py b/awscli/customizations/login/login.py index 7acf9ecec5d1..a41f1a23812a 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -24,10 +24,12 @@ ) from awscli.customizations.prompts import yes_no_choice from awscli.customizations.sso.utils import ( + REDIRECT_PORT_ARG, AuthCodeFetcher, OpenBrowserHandler, PrintOnlyHandler, open_browser_with_original_ld_path, + validate_redirect_port, ) from awscli.customizations.utils import uni_print @@ -60,7 +62,8 @@ class LoginCommand(BasicCommand): 'intended when running the CLI on remote hosts via SSH ' 'where a local browser is not available.' ), - } + }, + REDIRECT_PORT_ARG, ] def __init__( @@ -85,6 +88,7 @@ def __init__( self._config_file_writer = config_file_writer def _run_main(self, parsed_args, parsed_globals): + validate_redirect_port(parsed_args.redirect_port) region = self._resolve_region(parsed_globals) profile_name = self.resolve_profile_name() sign_in_type = self.resolve_sign_in_type(parsed_args) @@ -115,7 +119,9 @@ def _run_main(self, parsed_args, parsed_globals): if sign_in_type is LoginType.SAME_DEVICE: token_fetcher = SameDeviceLoginTokenFetcher( client=client, - auth_code_fetcher=AuthCodeFetcher(), + auth_code_fetcher=AuthCodeFetcher( + redirect_port=parsed_args.redirect_port + ), on_pending_authorization=OpenBrowserHandler( open_browser=open_browser_with_original_ld_path ), diff --git a/awscli/customizations/sso/login.py b/awscli/customizations/sso/login.py index 99145a58c6bb..0e40955d48e3 100644 --- a/awscli/customizations/sso/login.py +++ b/awscli/customizations/sso/login.py @@ -21,6 +21,7 @@ BaseSSOCommand, PrintOnlyHandler, do_sso_login, + validate_redirect_port, ) from awscli.customizations.utils import uni_print @@ -49,6 +50,7 @@ class LoginCommand(BaseSSOCommand): ] def _run_main(self, parsed_args, parsed_globals): + validate_redirect_port(parsed_args.redirect_port) sso_config = self._get_sso_config(sso_session=parsed_args.sso_session) start_url = sso_config['sso_start_url'] configured_region = sso_config.get('sso_region') @@ -81,6 +83,7 @@ def _run_main(self, parsed_args, parsed_globals): session_name=sso_config.get('session_name'), registration_scopes=sso_config.get('registration_scopes'), use_device_code=parsed_args.use_device_code, + redirect_port=parsed_args.redirect_port, ) # Only rewrite sso_region after successful login. diff --git a/awscli/customizations/sso/utils.py b/awscli/customizations/sso/utils.py index 564203dae501..150411d821ef 100644 --- a/awscli/customizations/sso/utils.py +++ b/awscli/customizations/sso/utils.py @@ -35,13 +35,29 @@ from awscli import __version__ as awscli_version from awscli.customizations.assumerole import CACHE_DIR as AWS_CREDS_CACHE_DIR from awscli.customizations.commands import BasicCommand -from awscli.customizations.exceptions import ConfigurationError +from awscli.customizations.exceptions import ( + ConfigurationError, + ParamValidationError, +) from awscli.customizations.utils import uni_print LOG = logging.getLogger(__name__) SSO_TOKEN_DIR = os.path.expanduser(os.path.join('~', '.aws', 'sso', 'cache')) +REDIRECT_PORT_ARG = { + 'name': 'redirect-port', + 'cli_type_name': 'integer', + 'help_text': ( + 'The port on localhost that the local callback server listens on, ' + 'which is used to build the ``redirect_uri`` for the authorization ' + 'request. By default an available port is chosen at random. Set this ' + 'when the port must be known in advance, such as when mapping it from ' + 'a Docker container to the host. Has no effect when a flow that does ' + 'not start a callback server is used.' + ), +} + LOGIN_ARGS = [ { 'name': 'no-browser', @@ -61,9 +77,21 @@ 'instead of the Authorization Code flow.' ), }, + REDIRECT_PORT_ARG, ] +def validate_redirect_port(redirect_port): + """Validates a ``--redirect-port`` value before starting a login flow.""" + if redirect_port is None: + return + if not 1 <= redirect_port <= 65535: + raise ParamValidationError( + f'Invalid value for --redirect-port: {redirect_port}. ' + 'Value must be between 1 and 65535.' + ) + + def _serialize_utc_timestamp(obj): if isinstance(obj, datetime.datetime): return obj.strftime('%Y-%m-%dT%H:%M:%SZ') @@ -86,6 +114,7 @@ def do_sso_login( session_name=None, use_device_code=False, resolved_start_url=None, + redirect_port=None, ): if token_cache is None: token_cache = JSONFileCache(SSO_TOKEN_DIR, dumps_func=_sso_json_dumps) @@ -101,7 +130,7 @@ def do_sso_login( sso_region=sso_region, client_creator=session.create_client, parsed_globals=parsed_globals, - auth_code_fetcher=AuthCodeFetcher(), + auth_code_fetcher=AuthCodeFetcher(redirect_port=redirect_port), cache=token_cache, on_pending_authorization=on_pending_authorization, ) @@ -230,16 +259,20 @@ class AuthCodeFetcher: # How long we wait overall for the callback _OVERALL_TIMEOUT = 60 * 10 - def __init__(self): + def __init__(self, redirect_port=None): self._auth_code = None self._state = None self._is_done = False + # Binding to port 0 lets the OS pick any available port, which is what + # we want unless the caller needs the port to be known in advance. + port = 0 if redirect_port is None else redirect_port + # We do this so that the request handler can have a reference to this # AuthCodeFetcher so that it can pass back the state and auth code try: handler = partial(OAuthCallbackHandler, self) - self.http_server = HTTPServer(('', 0), handler) + self.http_server = HTTPServer(('', port), handler) self.http_server.timeout = self._REQUEST_TIMEOUT except OSError as e: raise AuthCodeFetcherError(error_msg=e) diff --git a/tests/functional/login/test_login.py b/tests/functional/login/test_login.py index a89fa0a31670..ce8b661ee31e 100644 --- a/tests/functional/login/test_login.py +++ b/tests/functional/login/test_login.py @@ -6,10 +6,13 @@ import pytest -from awscli.customizations.exceptions import ConfigurationError +from awscli.customizations.exceptions import ( + ConfigurationError, + ParamValidationError, +) from awscli.customizations.login.login import LoginCommand -DEFAULT_ARGS = Namespace(remote=False) +DEFAULT_ARGS = Namespace(remote=False, redirect_port=None) DEFAULT_GLOBAL_ARGS = Namespace( region='us-east-1', endpoint_url=None, verify_ssl=None ) @@ -104,6 +107,50 @@ def test_run_main_same_device_flow( ) +@mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') +@mock.patch('awscli.customizations.login.login.AuthCodeFetcher') +@mock.patch( + 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' +) +@pytest.mark.parametrize('redirect_port', [None, 34535]) +def test_run_main_passes_redirect_port_to_auth_code_fetcher( + mock_token_fetcher, + mock_auth_code_fetcher, + mock_base_sign_in_uri, + mock_login_command, + redirect_port, +): + 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', + ) + args = Namespace(**vars(DEFAULT_ARGS)) + args.redirect_port = redirect_port + + mock_login_command._run_main(args, DEFAULT_GLOBAL_ARGS) + + mock_auth_code_fetcher.assert_called_once_with(redirect_port=redirect_port) + + +@mock.patch('awscli.customizations.login.login.AuthCodeFetcher') +def test_run_main_rejects_out_of_range_redirect_port( + mock_auth_code_fetcher, mock_login_command +): + args = Namespace(**vars(DEFAULT_ARGS)) + args.redirect_port = 65536 + + with pytest.raises(ParamValidationError) as excinfo: + mock_login_command._run_main(args, DEFAULT_GLOBAL_ARGS) + + assert 'must be between 1 and 65535' in str(excinfo.value) + mock_auth_code_fetcher.assert_not_called() + + @mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') @mock.patch( 'awscli.customizations.login.utils.CrossDeviceLoginTokenFetcher.fetch_token' diff --git a/tests/functional/sso/test_login.py b/tests/functional/sso/test_login.py index 89d0a3612c03..e09a056ea8cb 100644 --- a/tests/functional/sso/test_login.py +++ b/tests/functional/sso/test_login.py @@ -443,6 +443,29 @@ def test_login_auth_sso_state_mismatch_throws_error(self): _, stderr, _ = self.run_cmd('sso login', expected_rc=255) self.assertIn('State parameter does not match expected value.', stderr) + def test_login_auth_default_redirect_port(self): + content = self.get_sso_session_config('test-session') + self.set_config_file_content(content=content) + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + self.fetcher_mock.assert_called_once_with(redirect_port=None) + + def test_login_auth_explicit_redirect_port(self): + content = self.get_sso_session_config('test-session') + self.set_config_file_content(content=content) + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login --redirect-port 34535') + self.fetcher_mock.assert_called_once_with(redirect_port=34535) + + def test_login_rejects_out_of_range_redirect_port(self): + content = self.get_sso_session_config('test-session') + self.set_config_file_content(content=content) + _, stderr, _ = self.run_cmd( + 'sso login --redirect-port 65536', expected_rc=252 + ) + self.assertIn('must be between 1 and 65535', stderr) + self.fetcher_mock.assert_not_called() + def test_login_device_no_extra_user_agent(self): self.add_oidc_device_responses(self.access_token) self.run_cmd('sso login --use-device-code') diff --git a/tests/unit/customizations/configure/test_sso.py b/tests/unit/customizations/configure/test_sso.py index e7d5c7065da1..f7978abfb8f7 100644 --- a/tests/unit/customizations/configure/test_sso.py +++ b/tests/unit/customizations/configure/test_sso.py @@ -41,6 +41,7 @@ display_account, get_account_sorting_key, ) +from awscli.customizations.exceptions import ParamValidationError from awscli.customizations.sso.utils import ( PrintOnlyHandler, do_sso_login, @@ -881,6 +882,7 @@ def assert_do_sso_login_call( expected_scopes=None, expected_auth_handler_cls=None, expected_force_refresh=None, + expected_redirect_port=None, ): expected_kwargs = { "sso_region": expected_sso_region, @@ -889,6 +891,7 @@ def assert_do_sso_login_call( "on_pending_authorization": None, "token_cache": None, "use_device_code": expected_use_device_code, + "redirect_port": expected_redirect_port, } if expected_session_name is not None: expected_kwargs["session_name"] = expected_session_name @@ -957,6 +960,44 @@ def test_legacy_configure_sso_flow( in stdout ) + def test_configure_sso_passes_redirect_port( + self, + sso_cmd, + ptk_stubber, + aws_config, + stub_simple_single_item_sso_responses, + mock_do_sso_login, + botocore_session, + parsed_globals, + configure_sso_legacy_inputs, + account_id, + role_name, + ): + inputs = configure_sso_legacy_inputs + inputs.skip_account_and_role_selection() + ptk_stubber.user_inputs = inputs + stub_simple_single_item_sso_responses(account_id, role_name) + + sso_cmd(["--redirect-port", "34535"], parsed_globals) + self.assert_do_sso_login_call( + mock_do_sso_login, + botocore_session, + expected_sso_region=inputs.sso_region_prompt.answer, + expected_start_url=inputs.start_url_prompt.answer, + expected_redirect_port=34535, + ) + + def test_configure_sso_rejects_out_of_range_redirect_port( + self, + sso_cmd, + parsed_globals, + mock_do_sso_login, + ): + with pytest.raises(ParamValidationError) as excinfo: + sso_cmd(["--redirect-port", "65536"], parsed_globals) + assert "must be between 1 and 65535" in str(excinfo.value) + mock_do_sso_login.assert_not_called() + def test_single_account_single_role_flow_no_browser( self, sso_cmd, diff --git a/tests/unit/customizations/sso/test_utils.py b/tests/unit/customizations/sso/test_utils.py index 647132b8e143..f71fd813edc0 100644 --- a/tests/unit/customizations/sso/test_utils.py +++ b/tests/unit/customizations/sso/test_utils.py @@ -11,15 +11,20 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. import os +import socket import threading import webbrowser import pytest import urllib3 -from botocore.exceptions import PendingAuthorizationExpiredError +from botocore.exceptions import ( + AuthCodeFetcherError, + PendingAuthorizationExpiredError, +) from botocore.session import Session from awscli.compat import BytesIO, StringIO +from awscli.customizations.exceptions import ParamValidationError from awscli.customizations.sso.utils import ( AuthCodeFetcher, OAuthCallbackHandler, @@ -28,6 +33,7 @@ do_sso_login, open_browser_with_original_ld_path, parse_sso_registration_scopes, + validate_redirect_port, ) from awscli.testutils import mock, unittest @@ -330,3 +336,50 @@ def test_get_auth_code_and_state_timeout(): """ with pytest.raises(PendingAuthorizationExpiredError): AuthCodeFetcher().get_auth_code_and_state() + + +def _get_available_port(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(('', 0)) + return sock.getsockname()[1] + + +def test_auth_code_fetcher_binds_random_port_by_default(): + fetcher = AuthCodeFetcher() + try: + assert fetcher.http_server.server_port != 0 + finally: + fetcher.http_server.server_close() + + +def test_auth_code_fetcher_binds_requested_redirect_port(): + redirect_port = _get_available_port() + fetcher = AuthCodeFetcher(redirect_port=redirect_port) + try: + assert fetcher.http_server.server_port == redirect_port + assert fetcher.redirect_uri_with_port() == ( + f'http://127.0.0.1:{redirect_port}/oauth/callback' + ) + finally: + fetcher.http_server.server_close() + + +@pytest.mark.parametrize('redirect_port', [None, 1, 8080, 65535]) +def test_validate_redirect_port_allows_valid_ports(redirect_port): + validate_redirect_port(redirect_port) + + +@pytest.mark.parametrize('redirect_port', [-1, 0, 65536]) +def test_validate_redirect_port_rejects_out_of_range_ports(redirect_port): + with pytest.raises(ParamValidationError) as excinfo: + validate_redirect_port(redirect_port) + assert 'must be between 1 and 65535' in str(excinfo.value) + + +def test_auth_code_fetcher_errors_when_redirect_port_in_use(): + occupied = AuthCodeFetcher() + try: + with pytest.raises(AuthCodeFetcherError): + AuthCodeFetcher(redirect_port=occupied.http_server.server_port) + finally: + occupied.http_server.server_close() From 676b6b0889a882a6128610a0f6583f9f71368025 Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Thu, 17 Sep 2026 10:06:30 -0400 Subject: [PATCH 2/2] Fix test on Windows --- tests/unit/customizations/sso/test_utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/unit/customizations/sso/test_utils.py b/tests/unit/customizations/sso/test_utils.py index f71fd813edc0..6f24e9b36f17 100644 --- a/tests/unit/customizations/sso/test_utils.py +++ b/tests/unit/customizations/sso/test_utils.py @@ -340,7 +340,7 @@ def test_get_auth_code_and_state_timeout(): def _get_available_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(('', 0)) + sock.bind(('127.0.0.1', 0)) return sock.getsockname()[1] @@ -376,10 +376,10 @@ def test_validate_redirect_port_rejects_out_of_range_ports(redirect_port): assert 'must be between 1 and 65535' in str(excinfo.value) -def test_auth_code_fetcher_errors_when_redirect_port_in_use(): - occupied = AuthCodeFetcher() - try: - with pytest.raises(AuthCodeFetcherError): - AuthCodeFetcher(redirect_port=occupied.http_server.server_port) - finally: - occupied.http_server.server_close() +@mock.patch('awscli.customizations.sso.utils.HTTPServer') +def test_auth_code_fetcher_errors_when_redirect_port_in_use(http_server): + # Simulate an occupied port without relying on platform-specific binds. + http_server.side_effect = OSError('Address already in use') + + with pytest.raises(AuthCodeFetcherError): + AuthCodeFetcher(redirect_port=34535)