From 9562c07555a1413d69a1ea555b83757d50d31d48 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 10 Jun 2026 15:41:45 -0400 Subject: [PATCH 1/8] Add SSO start URL resolver for vanity domain support --- awscli/customizations/sso/resolve.py | 183 +++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 awscli/customizations/sso/resolve.py diff --git a/awscli/customizations/sso/resolve.py b/awscli/customizations/sso/resolve.py new file mode 100644 index 000000000000..97fc868ead58 --- /dev/null +++ b/awscli/customizations/sso/resolve.py @@ -0,0 +1,183 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import logging +import re +import urllib.error +import urllib.parse +import urllib.request + +from awscli.customizations.exceptions import ConfigurationError + +LOG = logging.getLogger(__name__) + +_AWS_OWNED_SUFFIXES = ( + '.app.aws', + '.portal.amazonaws.com', + '.awsapps.com', +) + +_AWS_OWNED_EXACT = 'identitycenter.amazonaws.com' + +_REGION_PATTERNS = ( + # {idcInstanceId}.portal.{region}.app.aws + re.compile( + r'^[^.]+\.portal\.(?P[a-z0-9-]+)\.app\.aws$', re.IGNORECASE + ), + # {idcInstanceId}.{region}.portal.amazonaws.com + re.compile( + r'^[^.]+\.(?P[a-z0-9-]+)\.portal\.amazonaws\.com$', + re.IGNORECASE, + ), +) + +_ALL_PARTITIONS = ('aws', 'aws-cn', 'aws-us-gov') + +_MAX_REDIRECTS = 1 + + +def is_aws_owned_domain(hostname): + hostname = hostname.lower().rstrip('.') + if hostname == _AWS_OWNED_EXACT: + return True + for suffix in _AWS_OWNED_SUFFIXES: + if hostname == suffix.lstrip('.'): + return True + if hostname.endswith(suffix): + return True + return False + + +def _extract_region_from_hostname(hostname): + hostname = hostname.lower().rstrip('.') + for pattern in _REGION_PATTERNS: + match = pattern.match(hostname) + if match: + return match.group('region') + return None + + +def _validate_region(region, session): + available = set() + for partition in _ALL_PARTITIONS: + available.update( + session.get_available_regions('sso-oidc', partition_name=partition) + ) + if region not in available: + raise ConfigurationError( + f"Region '{region}' parsed from the resolved start URL is not " + f"a known AWS region. Verify the start URL is correct." + ) + + +def _follow_redirect(url): + class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + opener = urllib.request.build_opener(_NoRedirectHandler) + redirect_codes = (301, 302, 303, 307, 308) + + for _attempt in range(_MAX_REDIRECTS): + try: + req = urllib.request.Request(url, method='HEAD') + resp = opener.open(req, timeout=10) + resp.close() + return url + except urllib.error.HTTPError as e: + if e.code == 405: + try: + req = urllib.request.Request(url, method='GET') + resp = opener.open(req, timeout=10) + resp.close() + return url + except urllib.error.HTTPError as e2: + if e2.code in redirect_codes: + location = e2.headers.get('Location') + if not location: + raise ConfigurationError( + "Redirect response missing Location header." + ) + url = urllib.parse.urljoin(url, location) + else: + raise ConfigurationError( + f"Failed to resolve start URL: HTTP {e2.code}" + ) + except urllib.error.URLError as e2: + raise ConfigurationError( + f"Failed to resolve start URL: {e2.reason}" + ) + elif e.code in redirect_codes: + location = e.headers.get('Location') + if not location: + raise ConfigurationError( + "Redirect response missing Location header." + ) + url = urllib.parse.urljoin(url, location) + else: + raise ConfigurationError( + f"Failed to resolve start URL: HTTP {e.code}" + ) + except urllib.error.URLError as e: + raise ConfigurationError( + f"Failed to resolve start URL: {e.reason}" + ) + + return url + + +def resolve_start_url(start_url, session, configured_region=None): + parsed = urllib.parse.urlparse(start_url) + + if parsed.scheme != 'https': + raise ConfigurationError( + "The sso_start_url must use the https scheme." + ) + + hostname = parsed.hostname + if not hostname: + raise ConfigurationError(f"Invalid sso_start_url: '{start_url}'") + + if is_aws_owned_domain(hostname): + resolved_url = start_url + else: + LOG.debug( + "Start URL '%s' is not AWS-owned, following redirects", start_url + ) + resolved_url = _follow_redirect(start_url) + + resolved_hostname = urllib.parse.urlparse(resolved_url).hostname + if not resolved_hostname or not is_aws_owned_domain(resolved_hostname): + raise ConfigurationError( + f"Could not resolve start URL '{start_url}' to an " + f"AWS-owned endpoint. Final URL: '{resolved_url}'" + ) + + if urllib.parse.urlparse(resolved_url).scheme != 'https': + raise ConfigurationError( + f"Resolved URL must use https. Got: '{resolved_url}'" + ) + + resolved_hostname = urllib.parse.urlparse(resolved_url).hostname + region = _extract_region_from_hostname(resolved_hostname) + + if region: + _validate_region(region, session) + elif configured_region: + region = configured_region + else: + raise ConfigurationError( + f"Cannot determine region from start URL '{start_url}'. " + f"Please provide sso_region in your configuration." + ) + + return resolved_url, region From 6568696177eb67ff2ff00642d6ed1a420b575560 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 10 Jun 2026 16:29:26 -0400 Subject: [PATCH 2/8] Add unit tests for SSO start URL resolver --- awscli/customizations/sso/resolve.py | 2 +- tests/unit/customizations/sso/test_resolve.py | 334 ++++++++++++++++++ 2 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 tests/unit/customizations/sso/test_resolve.py diff --git a/awscli/customizations/sso/resolve.py b/awscli/customizations/sso/resolve.py index 97fc868ead58..92ea0279da3a 100644 --- a/awscli/customizations/sso/resolve.py +++ b/awscli/customizations/sso/resolve.py @@ -94,7 +94,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): resp.close() return url except urllib.error.HTTPError as e: - if e.code == 405: + if e.code in (405, 501): try: req = urllib.request.Request(url, method='GET') resp = opener.open(req, timeout=10) diff --git a/tests/unit/customizations/sso/test_resolve.py b/tests/unit/customizations/sso/test_resolve.py new file mode 100644 index 000000000000..69530505fc18 --- /dev/null +++ b/tests/unit/customizations/sso/test_resolve.py @@ -0,0 +1,334 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import urllib.error + +import pytest + +from awscli.customizations.exceptions import ConfigurationError +from awscli.customizations.sso.resolve import ( + _extract_region_from_hostname, + _follow_redirect, + is_aws_owned_domain, + resolve_start_url, +) +from awscli.testutils import mock + + +class TestIsAwsOwnedDomain: + @pytest.mark.parametrize( + 'hostname', + [ + 'ssoins-abc123.portal.us-west-2.app.aws', + 'ssoins-abc123.portal.us-east-1.app.aws', + 'SSOINS-ABC123.PORTAL.US-WEST-2.APP.AWS', + 'ssoins-abc123.us-west-2.portal.amazonaws.com', + 'd-abc123.awsapps.com', + 'myalias.awsapps.com', + 'awsapps.com', + 'identitycenter.amazonaws.com', + 'identitycenter.amazonaws.com.', + ], + ) + def test_aws_owned_returns_true(self, hostname): + assert is_aws_owned_domain(hostname) is True + + @pytest.mark.parametrize( + 'hostname', + [ + 'aws.mycompany.com', + 'awsapps.com.evil.net', + 'evil-awsapps.com', + 'x.app.aws.example.com', + 'amazonaws.com.example.net', + 'portal.amazonaws.com.evil.net', + 'notidentitycenter.amazonaws.com', + 'example.com', + '', + ], + ) + def test_non_aws_owned_returns_false(self, hostname): + assert is_aws_owned_domain(hostname) is False + + +class TestExtractRegionFromHostname: + @pytest.mark.parametrize( + 'hostname, expected_region', + [ + ('ssoins-abc.portal.us-west-2.app.aws', 'us-west-2'), + ('ssoins-abc.portal.us-east-1.app.aws', 'us-east-1'), + ('ssoins-abc.portal.eu-west-1.app.aws', 'eu-west-1'), + ('ssoins-abc.portal.cn-north-1.app.aws', 'cn-north-1'), + ('ssoins-abc.us-gov-west-1.portal.amazonaws.com', 'us-gov-west-1'), + ('ssoins-abc.us-west-2.portal.amazonaws.com', 'us-west-2'), + ], + ) + def test_extracts_region(self, hostname, expected_region): + assert _extract_region_from_hostname(hostname) == expected_region + + @pytest.mark.parametrize( + 'hostname', + [ + 'd-abc123.awsapps.com', + 'identitycenter.amazonaws.com', + 'aws.mycompany.com', + ], + ) + def test_returns_none_for_region_less_hostnames(self, hostname): + assert _extract_region_from_hostname(hostname) is None + + +class TestResolveStartUrl: + def _mock_session(self, regions=None): + if regions is None: + regions = [ + 'us-east-1', + 'us-west-2', + 'eu-west-1', + 'us-gov-west-1', + 'cn-north-1', + ] + session = mock.Mock() + session.get_available_regions.return_value = regions + return session + + def test_aws_owned_url_returns_immediately(self): + session = self._mock_session() + resolved_url, region = resolve_start_url( + 'https://ssoins-abc.portal.us-west-2.app.aws', + session=session, + ) + assert resolved_url == 'https://ssoins-abc.portal.us-west-2.app.aws' + assert region == 'us-west-2' + + def test_aws_owned_url_no_network_call(self): + session = self._mock_session() + with mock.patch('urllib.request.build_opener') as mock_opener: + resolve_start_url( + 'https://ssoins-abc.portal.us-west-2.app.aws', + session=session, + ) + mock_opener.assert_not_called() + + def test_awsapps_url_requires_configured_region(self): + session = self._mock_session() + with pytest.raises( + ConfigurationError, match='Cannot determine region' + ): + resolve_start_url( + 'https://d-abc123.awsapps.com/start', + session=session, + ) + + def test_awsapps_url_uses_configured_region(self): + session = self._mock_session() + resolved_url, region = resolve_start_url( + 'https://d-abc123.awsapps.com/start', + session=session, + configured_region='us-east-1', + ) + assert resolved_url == 'https://d-abc123.awsapps.com/start' + assert region == 'us-east-1' + + def test_http_scheme_raises_error(self): + session = self._mock_session() + with pytest.raises(ConfigurationError, match='https scheme'): + resolve_start_url('http://aws.mycompany.com', session=session) + + def test_invalid_url_raises_error(self): + session = self._mock_session() + with pytest.raises(ConfigurationError, match='Invalid sso_start_url'): + resolve_start_url('https://', session=session) + + def test_invalid_region_raises_error(self): + session = self._mock_session(regions=['us-east-1', 'us-west-2']) + with pytest.raises(ConfigurationError, match='not a known AWS region'): + resolve_start_url( + 'https://ssoins-abc.portal.fake-region-1.app.aws', + session=session, + ) + + def test_invalid_parsed_region_does_not_fall_back_to_configured(self): + session = self._mock_session(regions=['us-east-1', 'us-west-2']) + with pytest.raises(ConfigurationError, match='not a known AWS region'): + resolve_start_url( + 'https://ssoins-abc.portal.fake-region-1.app.aws', + session=session, + configured_region='us-east-1', + ) + + def test_govcloud_region_accepted(self): + session = mock.Mock() + session.get_available_regions.side_effect = ( + lambda service, partition_name='aws': { + 'aws': ['us-east-1', 'us-west-2'], + 'aws-us-gov': ['us-gov-west-1'], + 'aws-cn': ['cn-north-1'], + }.get(partition_name, []) + ) + resolved_url, region = resolve_start_url( + 'https://ssoins-abc.portal.us-gov-west-1.app.aws', + session=session, + ) + assert region == 'us-gov-west-1' + + def test_china_region_accepted(self): + session = mock.Mock() + session.get_available_regions.side_effect = ( + lambda service, partition_name='aws': { + 'aws': ['us-east-1', 'us-west-2'], + 'aws-us-gov': ['us-gov-west-1'], + 'aws-cn': ['cn-north-1'], + }.get(partition_name, []) + ) + resolved_url, region = resolve_start_url( + 'https://ssoins-abc.portal.cn-north-1.app.aws', + session=session, + ) + assert region == 'cn-north-1' + + def test_vanity_url_follows_redirect(self): + session = self._mock_session() + redirect_url = 'https://ssoins-abc.portal.us-east-1.app.aws:443/' + + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = redirect_url + resolved_url, region = resolve_start_url( + 'https://aws.mycompany.com', + session=session, + ) + assert resolved_url == redirect_url + assert region == 'us-east-1' + mock_follow.assert_called_once_with('https://aws.mycompany.com') + + def test_vanity_url_resolves_to_non_aws_domain_raises_error(self): + session = self._mock_session() + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = 'https://not-aws.example.com' + with pytest.raises(ConfigurationError, match='Could not resolve'): + resolve_start_url('https://aws.mycompany.com', session=session) + + def test_vanity_url_resolves_to_http_raises_error(self): + session = self._mock_session() + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = ( + 'http://ssoins-abc.portal.us-east-1.app.aws' + ) + with pytest.raises(ConfigurationError, match='must use https'): + resolve_start_url('https://aws.mycompany.com', session=session) + + +class TestFollowRedirect: + def _make_http_error(self, code, headers=None): + if headers is None: + headers = {} + import http.client + + msg = http.client.HTTPMessage() + for k, v in headers.items(): + msg[k] = v + return urllib.error.HTTPError( + url='https://example.com', + code=code, + msg='', + hdrs=msg, + fp=None, + ) + + def test_head_redirect_returns_location(self): + redirect_target = 'https://ssoins-abc.portal.us-east-1.app.aws' + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = self._make_http_error( + 302, {'Location': redirect_target} + ) + result = _follow_redirect('https://aws.mycompany.com') + assert result == redirect_target + + @pytest.mark.parametrize('head_error_code', [405, 501]) + def test_head_unsupported_falls_back_to_get(self, head_error_code): + redirect_target = 'https://ssoins-abc.portal.us-east-1.app.aws' + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = [ + self._make_http_error(head_error_code), + self._make_http_error(302, {'Location': redirect_target}), + ] + result = _follow_redirect('https://aws.mycompany.com') + assert result == redirect_target + assert mock_opener.open.call_count == 2 + + def test_head_200_returns_original_url(self): + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_response = mock.Mock() + mock_opener.open.return_value = mock_response + result = _follow_redirect('https://aws.mycompany.com') + assert result == 'https://aws.mycompany.com' + mock_response.close.assert_called_once() + + def test_missing_location_header_raises_error(self): + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = self._make_http_error(302, {}) + with pytest.raises(ConfigurationError, match='missing Location'): + _follow_redirect('https://aws.mycompany.com') + + def test_non_redirect_error_raises_configuration_error(self): + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = self._make_http_error(500) + with pytest.raises(ConfigurationError, match='HTTP 500'): + _follow_redirect('https://aws.mycompany.com') + + def test_url_error_raises_configuration_error(self): + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = urllib.error.URLError( + 'Name or service not known' + ) + with pytest.raises(ConfigurationError, match='Name or service'): + _follow_redirect('https://aws.mycompany.com') + + def test_relative_location_is_resolved(self): + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = self._make_http_error( + 302, {'Location': '/start'} + ) + result = _follow_redirect('https://aws.mycompany.com/portal') + assert result == 'https://aws.mycompany.com/start' + + def test_stops_after_max_redirects(self): + intermediate = 'https://intermediate.example.com/path' + with mock.patch('urllib.request.build_opener') as mock_build: + mock_opener = mock.Mock() + mock_build.return_value = mock_opener + mock_opener.open.side_effect = self._make_http_error( + 302, {'Location': intermediate} + ) + result = _follow_redirect('https://aws.mycompany.com') + assert result == intermediate + assert mock_opener.open.call_count == 1 From 9883332b8598117f0203879c1e284e551dba720b Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Fri, 12 Jun 2026 11:30:21 -0400 Subject: [PATCH 3/8] Update resolver so direct URLs use the configured region --- awscli/customizations/sso/resolve.py | 34 +++--- tests/unit/customizations/sso/test_resolve.py | 105 +++++++++++++----- 2 files changed, 97 insertions(+), 42 deletions(-) diff --git a/awscli/customizations/sso/resolve.py b/awscli/customizations/sso/resolve.py index 92ea0279da3a..5b084defedc4 100644 --- a/awscli/customizations/sso/resolve.py +++ b/awscli/customizations/sso/resolve.py @@ -148,26 +148,30 @@ def resolve_start_url(start_url, session, configured_region=None): raise ConfigurationError(f"Invalid sso_start_url: '{start_url}'") if is_aws_owned_domain(hostname): - resolved_url = start_url - else: - LOG.debug( - "Start URL '%s' is not AWS-owned, following redirects", start_url - ) - resolved_url = _follow_redirect(start_url) - - resolved_hostname = urllib.parse.urlparse(resolved_url).hostname - if not resolved_hostname or not is_aws_owned_domain(resolved_hostname): + if not configured_region: raise ConfigurationError( - f"Could not resolve start URL '{start_url}' to an " - f"AWS-owned endpoint. Final URL: '{resolved_url}'" + "Missing required configuration: sso_region. " + "Please run 'aws configure sso' to set it." ) + return start_url, configured_region - if urllib.parse.urlparse(resolved_url).scheme != 'https': - raise ConfigurationError( - f"Resolved URL must use https. Got: '{resolved_url}'" - ) + LOG.debug( + "Start URL '%s' is not AWS-owned, following redirects", start_url + ) + resolved_url = _follow_redirect(start_url) resolved_hostname = urllib.parse.urlparse(resolved_url).hostname + if not resolved_hostname or not is_aws_owned_domain(resolved_hostname): + raise ConfigurationError( + f"Could not resolve start URL '{start_url}' to an " + f"AWS-owned endpoint. Final URL: '{resolved_url}'" + ) + + if urllib.parse.urlparse(resolved_url).scheme != 'https': + raise ConfigurationError( + f"Resolved URL must use https. Got: '{resolved_url}'" + ) + region = _extract_region_from_hostname(resolved_hostname) if region: diff --git a/tests/unit/customizations/sso/test_resolve.py b/tests/unit/customizations/sso/test_resolve.py index 69530505fc18..d9611fa88062 100644 --- a/tests/unit/customizations/sso/test_resolve.py +++ b/tests/unit/customizations/sso/test_resolve.py @@ -101,11 +101,12 @@ def _mock_session(self, regions=None): session.get_available_regions.return_value = regions return session - def test_aws_owned_url_returns_immediately(self): + def test_aws_owned_url_returns_configured_region(self): session = self._mock_session() resolved_url, region = resolve_start_url( 'https://ssoins-abc.portal.us-west-2.app.aws', session=session, + configured_region='us-west-2', ) assert resolved_url == 'https://ssoins-abc.portal.us-west-2.app.aws' assert region == 'us-west-2' @@ -116,13 +117,35 @@ def test_aws_owned_url_no_network_call(self): resolve_start_url( 'https://ssoins-abc.portal.us-west-2.app.aws', session=session, + configured_region='us-west-2', ) mock_opener.assert_not_called() + def test_aws_owned_url_without_configured_region_raises_error(self): + session = self._mock_session() + with pytest.raises( + ConfigurationError, + match='Missing required configuration: sso_region', + ): + resolve_start_url( + 'https://ssoins-abc.portal.us-west-2.app.aws', + session=session, + ) + + def test_aws_owned_url_uses_configured_region_not_hostname(self): + session = self._mock_session() + resolved_url, region = resolve_start_url( + 'https://ssoins-abc.portal.us-west-2.app.aws', + session=session, + configured_region='us-east-1', + ) + assert region == 'us-east-1' + def test_awsapps_url_requires_configured_region(self): session = self._mock_session() with pytest.raises( - ConfigurationError, match='Cannot determine region' + ConfigurationError, + match='Missing required configuration: sso_region', ): resolve_start_url( 'https://d-abc123.awsapps.com/start', @@ -149,24 +172,40 @@ def test_invalid_url_raises_error(self): with pytest.raises(ConfigurationError, match='Invalid sso_start_url'): resolve_start_url('https://', session=session) - def test_invalid_region_raises_error(self): + def test_vanity_url_invalid_region_raises_error(self): session = self._mock_session(regions=['us-east-1', 'us-west-2']) - with pytest.raises(ConfigurationError, match='not a known AWS region'): - resolve_start_url( - 'https://ssoins-abc.portal.fake-region-1.app.aws', - session=session, + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = ( + 'https://ssoins-abc.portal.fake-region-1.app.aws' ) - - def test_invalid_parsed_region_does_not_fall_back_to_configured(self): + with pytest.raises( + ConfigurationError, match='not a known AWS region' + ): + resolve_start_url( + 'https://aws.mycompany.com', + session=session, + ) + + def test_vanity_url_invalid_region_does_not_fall_back_to_configured(self): session = self._mock_session(regions=['us-east-1', 'us-west-2']) - with pytest.raises(ConfigurationError, match='not a known AWS region'): - resolve_start_url( - 'https://ssoins-abc.portal.fake-region-1.app.aws', - session=session, - configured_region='us-east-1', + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = ( + 'https://ssoins-abc.portal.fake-region-1.app.aws' ) - - def test_govcloud_region_accepted(self): + with pytest.raises( + ConfigurationError, match='not a known AWS region' + ): + resolve_start_url( + 'https://aws.mycompany.com', + session=session, + configured_region='us-east-1', + ) + + def test_vanity_url_govcloud_region_accepted(self): session = mock.Mock() session.get_available_regions.side_effect = ( lambda service, partition_name='aws': { @@ -175,13 +214,19 @@ def test_govcloud_region_accepted(self): 'aws-cn': ['cn-north-1'], }.get(partition_name, []) ) - resolved_url, region = resolve_start_url( - 'https://ssoins-abc.portal.us-gov-west-1.app.aws', - session=session, - ) - assert region == 'us-gov-west-1' + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = ( + 'https://ssoins-abc.portal.us-gov-west-1.app.aws' + ) + resolved_url, region = resolve_start_url( + 'https://aws.mycompany.com', + session=session, + ) + assert region == 'us-gov-west-1' - def test_china_region_accepted(self): + def test_vanity_url_china_region_accepted(self): session = mock.Mock() session.get_available_regions.side_effect = ( lambda service, partition_name='aws': { @@ -190,11 +235,17 @@ def test_china_region_accepted(self): 'aws-cn': ['cn-north-1'], }.get(partition_name, []) ) - resolved_url, region = resolve_start_url( - 'https://ssoins-abc.portal.cn-north-1.app.aws', - session=session, - ) - assert region == 'cn-north-1' + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = ( + 'https://ssoins-abc.portal.cn-north-1.app.aws' + ) + resolved_url, region = resolve_start_url( + 'https://aws.mycompany.com', + session=session, + ) + assert region == 'cn-north-1' def test_vanity_url_follows_redirect(self): session = self._mock_session() From 0bab17c75edf0690193c13c104248fc18577ec9a Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 16 Jun 2026 11:54:01 -0400 Subject: [PATCH 4/8] Drop region validation, add URL normalization and configurable timeout --- awscli/customizations/sso/resolve.py | 62 +++---- tests/unit/customizations/sso/test_resolve.py | 160 +++++++++--------- 2 files changed, 108 insertions(+), 114 deletions(-) diff --git a/awscli/customizations/sso/resolve.py b/awscli/customizations/sso/resolve.py index 5b084defedc4..663ae072333e 100644 --- a/awscli/customizations/sso/resolve.py +++ b/awscli/customizations/sso/resolve.py @@ -40,10 +40,21 @@ ), ) -_ALL_PARTITIONS = ('aws', 'aws-cn', 'aws-us-gov') - _MAX_REDIRECTS = 1 +_DEFAULT_PORTS = {'https': 443, 'http': 80} + +_DEFAULT_TIMEOUT = 10 + + +def _normalize_url(url): + parsed = urllib.parse.urlparse(url) + netloc = parsed.hostname or '' + if parsed.port and parsed.port != _DEFAULT_PORTS.get(parsed.scheme): + netloc = f'{netloc}:{parsed.port}' + path = parsed.path.rstrip('/') + return urllib.parse.urlunparse((parsed.scheme, netloc, path, '', '', '')) + def is_aws_owned_domain(hostname): hostname = hostname.lower().rstrip('.') @@ -66,20 +77,7 @@ def _extract_region_from_hostname(hostname): return None -def _validate_region(region, session): - available = set() - for partition in _ALL_PARTITIONS: - available.update( - session.get_available_regions('sso-oidc', partition_name=partition) - ) - if region not in available: - raise ConfigurationError( - f"Region '{region}' parsed from the resolved start URL is not " - f"a known AWS region. Verify the start URL is correct." - ) - - -def _follow_redirect(url): +def _follow_redirect(url, timeout=_DEFAULT_TIMEOUT): class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None @@ -87,17 +85,17 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): opener = urllib.request.build_opener(_NoRedirectHandler) redirect_codes = (301, 302, 303, 307, 308) - for _attempt in range(_MAX_REDIRECTS): + for _ in range(_MAX_REDIRECTS): try: req = urllib.request.Request(url, method='HEAD') - resp = opener.open(req, timeout=10) + resp = opener.open(req, timeout=timeout) resp.close() return url except urllib.error.HTTPError as e: if e.code in (405, 501): try: req = urllib.request.Request(url, method='GET') - resp = opener.open(req, timeout=10) + resp = opener.open(req, timeout=timeout) resp.close() return url except urllib.error.HTTPError as e2: @@ -135,7 +133,9 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): return url -def resolve_start_url(start_url, session, configured_region=None): +def resolve_start_url( + start_url, session, configured_region=None, timeout=None +): parsed = urllib.parse.urlparse(start_url) if parsed.scheme != 'https': @@ -158,7 +158,10 @@ def resolve_start_url(start_url, session, configured_region=None): LOG.debug( "Start URL '%s' is not AWS-owned, following redirects", start_url ) - resolved_url = _follow_redirect(start_url) + effective_timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT + resolved_url = _normalize_url( + _follow_redirect(start_url, timeout=effective_timeout) + ) resolved_hostname = urllib.parse.urlparse(resolved_url).hostname if not resolved_hostname or not is_aws_owned_domain(resolved_hostname): @@ -174,14 +177,13 @@ def resolve_start_url(start_url, session, configured_region=None): region = _extract_region_from_hostname(resolved_hostname) - if region: - _validate_region(region, session) - elif configured_region: - region = configured_region - else: - raise ConfigurationError( - f"Cannot determine region from start URL '{start_url}'. " - f"Please provide sso_region in your configuration." - ) + if not region: + if configured_region: + region = configured_region + else: + raise ConfigurationError( + f"Cannot determine region from start URL '{start_url}'. " + f"Please provide sso_region in your configuration." + ) return resolved_url, region diff --git a/tests/unit/customizations/sso/test_resolve.py b/tests/unit/customizations/sso/test_resolve.py index d9611fa88062..1cd5237efece 100644 --- a/tests/unit/customizations/sso/test_resolve.py +++ b/tests/unit/customizations/sso/test_resolve.py @@ -18,6 +18,7 @@ from awscli.customizations.sso.resolve import ( _extract_region_from_hostname, _follow_redirect, + _normalize_url, is_aws_owned_domain, resolve_start_url, ) @@ -87,19 +88,25 @@ def test_returns_none_for_region_less_hostnames(self, hostname): assert _extract_region_from_hostname(hostname) is None +class TestNormalizeUrl: + @pytest.mark.parametrize( + 'url, expected', + [ + ('https://example.com:443/', 'https://example.com'), + ('https://example.com:443/path/', 'https://example.com/path'), + ('https://example.com/', 'https://example.com'), + ('https://example.com:8443/path', 'https://example.com:8443/path'), + ('http://example.com:80/', 'http://example.com'), + ('https://example.com', 'https://example.com'), + ], + ) + def test_normalize_url(self, url, expected): + assert _normalize_url(url) == expected + + class TestResolveStartUrl: - def _mock_session(self, regions=None): - if regions is None: - regions = [ - 'us-east-1', - 'us-west-2', - 'eu-west-1', - 'us-gov-west-1', - 'cn-north-1', - ] - session = mock.Mock() - session.get_available_regions.return_value = regions - return session + def _mock_session(self): + return mock.Mock() def test_aws_owned_url_returns_configured_region(self): session = self._mock_session() @@ -139,6 +146,7 @@ def test_aws_owned_url_uses_configured_region_not_hostname(self): session=session, configured_region='us-east-1', ) + assert resolved_url == 'https://ssoins-abc.portal.us-west-2.app.aws' assert region == 'us-east-1' def test_awsapps_url_requires_configured_region(self): @@ -172,96 +180,39 @@ def test_invalid_url_raises_error(self): with pytest.raises(ConfigurationError, match='Invalid sso_start_url'): resolve_start_url('https://', session=session) - def test_vanity_url_invalid_region_raises_error(self): - session = self._mock_session(regions=['us-east-1', 'us-west-2']) - with mock.patch( - 'awscli.customizations.sso.resolve._follow_redirect' - ) as mock_follow: - mock_follow.return_value = ( - 'https://ssoins-abc.portal.fake-region-1.app.aws' - ) - with pytest.raises( - ConfigurationError, match='not a known AWS region' - ): - resolve_start_url( - 'https://aws.mycompany.com', - session=session, - ) + def test_vanity_url_follows_redirect(self): + session = self._mock_session() + redirect_url = 'https://ssoins-abc.portal.us-east-1.app.aws:443/' + normalized_url = 'https://ssoins-abc.portal.us-east-1.app.aws' - def test_vanity_url_invalid_region_does_not_fall_back_to_configured(self): - session = self._mock_session(regions=['us-east-1', 'us-west-2']) - with mock.patch( - 'awscli.customizations.sso.resolve._follow_redirect' - ) as mock_follow: - mock_follow.return_value = ( - 'https://ssoins-abc.portal.fake-region-1.app.aws' - ) - with pytest.raises( - ConfigurationError, match='not a known AWS region' - ): - resolve_start_url( - 'https://aws.mycompany.com', - session=session, - configured_region='us-east-1', - ) - - def test_vanity_url_govcloud_region_accepted(self): - session = mock.Mock() - session.get_available_regions.side_effect = ( - lambda service, partition_name='aws': { - 'aws': ['us-east-1', 'us-west-2'], - 'aws-us-gov': ['us-gov-west-1'], - 'aws-cn': ['cn-north-1'], - }.get(partition_name, []) - ) with mock.patch( 'awscli.customizations.sso.resolve._follow_redirect' ) as mock_follow: - mock_follow.return_value = ( - 'https://ssoins-abc.portal.us-gov-west-1.app.aws' - ) + mock_follow.return_value = redirect_url resolved_url, region = resolve_start_url( 'https://aws.mycompany.com', session=session, ) - assert region == 'us-gov-west-1' - - def test_vanity_url_china_region_accepted(self): - session = mock.Mock() - session.get_available_regions.side_effect = ( - lambda service, partition_name='aws': { - 'aws': ['us-east-1', 'us-west-2'], - 'aws-us-gov': ['us-gov-west-1'], - 'aws-cn': ['cn-north-1'], - }.get(partition_name, []) - ) - with mock.patch( - 'awscli.customizations.sso.resolve._follow_redirect' - ) as mock_follow: - mock_follow.return_value = ( - 'https://ssoins-abc.portal.cn-north-1.app.aws' - ) - resolved_url, region = resolve_start_url( - 'https://aws.mycompany.com', - session=session, + assert resolved_url == normalized_url + assert region == 'us-east-1' + mock_follow.assert_called_once_with( + 'https://aws.mycompany.com', timeout=10 ) - assert region == 'cn-north-1' - def test_vanity_url_follows_redirect(self): + def test_vanity_url_uses_parsed_region_not_configured(self): session = self._mock_session() - redirect_url = 'https://ssoins-abc.portal.us-east-1.app.aws:443/' - with mock.patch( 'awscli.customizations.sso.resolve._follow_redirect' ) as mock_follow: - mock_follow.return_value = redirect_url + mock_follow.return_value = ( + 'https://ssoins-abc.portal.us-west-2.app.aws' + ) resolved_url, region = resolve_start_url( 'https://aws.mycompany.com', session=session, + configured_region='eu-west-1', ) - assert resolved_url == redirect_url - assert region == 'us-east-1' - mock_follow.assert_called_once_with('https://aws.mycompany.com') + assert region == 'us-west-2' def test_vanity_url_resolves_to_non_aws_domain_raises_error(self): session = self._mock_session() @@ -283,6 +234,47 @@ def test_vanity_url_resolves_to_http_raises_error(self): with pytest.raises(ConfigurationError, match='must use https'): resolve_start_url('https://aws.mycompany.com', session=session) + def test_vanity_url_region_less_requires_configured_region(self): + session = self._mock_session() + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = 'https://d-abc123.awsapps.com/start' + with pytest.raises( + ConfigurationError, match='Cannot determine region' + ): + resolve_start_url('https://aws.mycompany.com', session=session) + + def test_vanity_url_region_less_uses_configured_region(self): + session = self._mock_session() + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = 'https://d-abc123.awsapps.com/start' + resolved_url, region = resolve_start_url( + 'https://aws.mycompany.com', + session=session, + configured_region='us-east-1', + ) + assert region == 'us-east-1' + + def test_custom_timeout_passed_to_follow_redirect(self): + session = self._mock_session() + with mock.patch( + 'awscli.customizations.sso.resolve._follow_redirect' + ) as mock_follow: + mock_follow.return_value = ( + 'https://ssoins-abc.portal.us-east-1.app.aws' + ) + resolve_start_url( + 'https://aws.mycompany.com', + session=session, + timeout=5, + ) + mock_follow.assert_called_once_with( + 'https://aws.mycompany.com', timeout=5 + ) + class TestFollowRedirect: def _make_http_error(self, code, headers=None): From b2755f4baff365903f15e000d4fd4937cb62bf26 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 16 Jun 2026 12:34:26 -0400 Subject: [PATCH 5/8] Wire resolver into aws sso login with post-success config rewrite and tests --- awscli/botocore/utils.py | 10 +- awscli/customizations/sso/login.py | 48 +++++- awscli/customizations/sso/utils.py | 2 + tests/functional/sso/__init__.py | 19 ++- tests/functional/sso/test_login_resolve.py | 172 +++++++++++++++++++++ 5 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 tests/functional/sso/test_login_resolve.py diff --git a/awscli/botocore/utils.py b/awscli/botocore/utils.py index dbbfcd66abb1..dec7ec163441 100644 --- a/awscli/botocore/utils.py +++ b/awscli/botocore/utils.py @@ -3396,7 +3396,7 @@ def _authorize_client(self, start_url, registration): response = self._client.start_device_authorization( clientId=registration['clientId'], clientSecret=registration['clientSecret'], - startUrl=start_url, + startUrl=self._resolved_start_url, ) expires_in = datetime.timedelta(seconds=response['expiresIn']) authorization = { @@ -3514,7 +3514,11 @@ def fetch_token( force_refresh=False, registration_scopes=None, session_name=None, + resolved_start_url=None, ): + # resolved_start_url is the AWS-owned URL to pass to service APIs. + # start_url (possibly a vanity URL) is used as the cache key. + self._resolved_start_url = resolved_start_url or start_url return self._token( start_url, force_refresh, @@ -3607,7 +3611,7 @@ def _registration( session_name, scopes, self._auth_code_fetcher.redirect_uri_without_port(), - start_url, + self._resolved_start_url, ) self._cache[cache_key] = registration return registration @@ -3738,7 +3742,9 @@ def fetch_token( force_refresh=False, registration_scopes=None, session_name=None, + resolved_start_url=None, ): + self._resolved_start_url = resolved_start_url or start_url cache_key = self._token_cache_key(start_url, session_name) # Only obey the token cache if we are not forcing a refresh. if not force_refresh and cache_key in self._cache: diff --git a/awscli/customizations/sso/login.py b/awscli/customizations/sso/login.py index a16a946ed260..9089eb208a92 100644 --- a/awscli/customizations/sso/login.py +++ b/awscli/customizations/sso/login.py @@ -10,6 +10,14 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. +import os +from urllib.parse import urlparse + +from awscli.customizations.configure.writer import ConfigFileWriter +from awscli.customizations.sso.resolve import ( + is_aws_owned_domain, + resolve_start_url, +) from awscli.customizations.sso.utils import ( LOGIN_ARGS, BaseSSOCommand, @@ -44,20 +52,54 @@ class LoginCommand(BaseSSOCommand): def _run_main(self, parsed_args, parsed_globals): 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') + + resolved_url, region = resolve_start_url( + start_url, + session=self._session, + configured_region=configured_region, + timeout=parsed_globals.connect_timeout, + ) + on_pending_authorization = None if parsed_args.no_browser: on_pending_authorization = PrintOnlyHandler() do_sso_login( session=self._session, parsed_globals=parsed_globals, - sso_region=sso_config['sso_region'], - start_url=sso_config['sso_start_url'], + sso_region=region, + start_url=start_url, + resolved_start_url=resolved_url, on_pending_authorization=on_pending_authorization, force_refresh=True, session_name=sso_config.get('session_name'), registration_scopes=sso_config.get('registration_scopes'), use_device_code=parsed_args.use_device_code, ) + + # Only rewrite sso_region after successful login. + hostname = urlparse(start_url).hostname + if hostname and not is_aws_owned_domain(hostname): + if configured_region != region: + self._write_sso_region(sso_config, region) + success_msg = 'Successfully logged into Start URL: %s\n' - uni_print(success_msg % sso_config['sso_start_url']) + uni_print(success_msg % start_url) return 0 + + def _write_sso_region(self, sso_config, new_region): + session_name = sso_config.get('session_name') + config_path = os.path.expanduser( + self._session.get_config_variable('config_file') + ) + writer = ConfigFileWriter() + if session_name: + section = f'sso-session {session_name}' + else: + profile = self._session.get_config_variable('profile') or 'default' + section = f'profile {profile}' + writer.update_config( + {'__section__': section, 'sso_region': new_region}, + config_path, + ) diff --git a/awscli/customizations/sso/utils.py b/awscli/customizations/sso/utils.py index 4ee71ad85d7a..564203dae501 100644 --- a/awscli/customizations/sso/utils.py +++ b/awscli/customizations/sso/utils.py @@ -85,6 +85,7 @@ def do_sso_login( registration_scopes=None, session_name=None, use_device_code=False, + resolved_start_url=None, ): if token_cache is None: token_cache = JSONFileCache(SSO_TOKEN_DIR, dumps_func=_sso_json_dumps) @@ -118,6 +119,7 @@ def do_sso_login( session_name=session_name, force_refresh=force_refresh, registration_scopes=registration_scopes, + resolved_start_url=resolved_start_url, ) diff --git a/tests/functional/sso/__init__.py b/tests/functional/sso/__init__.py index fb6d9d2b0f61..2401b2e3d038 100644 --- a/tests/functional/sso/__init__.py +++ b/tests/functional/sso/__init__.py @@ -25,7 +25,7 @@ class BaseSSOTest(BaseAWSCommandParamsTest): def setUp(self): - super(BaseSSOTest, self).setUp() + super().setUp() self.files = FileCreator() self.start_url = 'https://mysigin.com' self.sso_region = 'us-west-2' @@ -42,6 +42,19 @@ def setUp(self): self.token_cache_dir, ) self.token_cache_dir_patch.start() + self._resolve_patch = mock.patch( + 'awscli.customizations.sso.login.resolve_start_url', + side_effect=lambda start_url, session, **kwargs: ( + start_url, + kwargs.get('configured_region', self.sso_region), + ), + ) + self._resolve_patch.start() + self._is_aws_owned_patch = mock.patch( + 'awscli.customizations.sso.login.is_aws_owned_domain', + return_value=True, + ) + self._is_aws_owned_patch.start() self.open_browser_mock = mock.Mock(spec=OpenBrowserHandler) self.open_browser_patch = mock.patch( 'awscli.customizations.sso.utils.OpenBrowserHandler', @@ -74,11 +87,13 @@ def setUp(self): self.expiration_time = time.time() + 1000 def tearDown(self): - super(BaseSSOTest, self).tearDown() + super().tearDown() self.files.remove_all() self.open_browser_patch.stop() self.auth_code_fetcher_patch.stop() self.uuid_patch.stop() + self._resolve_patch.stop() + self._is_aws_owned_patch.stop() self.token_cache_dir_patch.stop() def add_oidc_device_responses( diff --git a/tests/functional/sso/test_login_resolve.py b/tests/functional/sso/test_login_resolve.py new file mode 100644 index 000000000000..338e3d5f60e8 --- /dev/null +++ b/tests/functional/sso/test_login_resolve.py @@ -0,0 +1,172 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import os + +from awscli.testutils import mock +from tests.functional.sso import BaseSSOTest + + +class TestLoginWithVanityUrl(BaseSSOTest): + def setUp(self): + super().setUp() + self.vanity_url = 'https://aws.mycompany.com' + self.resolved_url = 'https://ssoins-abc.portal.us-east-1.app.aws' + self.start_url = self.vanity_url + self.sso_region = 'us-west-2' + self.set_config_file_content( + self.get_sso_session_config('vanity-session') + ) + self.resolve_patch = mock.patch( + 'awscli.customizations.sso.login.resolve_start_url', + return_value=(self.resolved_url, 'us-east-1'), + ) + self.mock_resolve = self.resolve_patch.start() + self.is_aws_owned_patch = mock.patch( + 'awscli.customizations.sso.login.is_aws_owned_domain', + return_value=False, + ) + self.mock_is_aws_owned = self.is_aws_owned_patch.start() + + def tearDown(self): + super().tearDown() + self.resolve_patch.stop() + self.is_aws_owned_patch.stop() + + def test_login_uses_resolved_region(self): + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + self.assert_used_expected_sso_region('us-east-1') + + def test_login_passes_resolved_url_to_register_client(self): + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + register_call = self.operations_called[0] + self.assertEqual(register_call[0].name, 'RegisterClient') + self.assertEqual(register_call[1]['issuerUrl'], self.resolved_url) + + def test_config_rewritten_after_successful_login(self): + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + with open(self.config_file) as f: + config_content = f.read() + self.assertIn('sso_region = us-east-1', config_content) + + def test_config_not_rewritten_on_login_failure(self): + self.parsed_responses = [ + { + 'Error': { + 'Code': 'InvalidRequestException', + 'Message': 'Invalid start url provided', + } + } + ] + self.run_cmd('sso login', expected_rc=254) + with open(self.config_file) as f: + config_content = f.read() + self.assertIn(f'sso_region={self.sso_region}', config_content) + + def test_config_not_rewritten_when_region_matches(self): + self.sso_region = 'us-east-1' + self.set_config_file_content( + self.get_sso_session_config('vanity-session') + ) + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + with open(self.config_file) as f: + config_content = f.read() + self.assertIn('sso_region=us-east-1', config_content) + + +class TestLoginWithVanityUrlLegacy(BaseSSOTest): + def setUp(self): + super().setUp() + self.vanity_url = 'https://aws.mycompany.com' + self.resolved_url = 'https://ssoins-abc.portal.us-east-1.app.aws' + self.start_url = self.vanity_url + self.sso_region = 'us-west-2' + self.set_config_file_content(self.get_legacy_config()) + self.resolve_patch = mock.patch( + 'awscli.customizations.sso.login.resolve_start_url', + return_value=(self.resolved_url, 'us-east-1'), + ) + self.mock_resolve = self.resolve_patch.start() + self.is_aws_owned_patch = mock.patch( + 'awscli.customizations.sso.login.is_aws_owned_domain', + return_value=False, + ) + self.mock_is_aws_owned = self.is_aws_owned_patch.start() + + def tearDown(self): + super().tearDown() + self.resolve_patch.stop() + self.is_aws_owned_patch.stop() + + def test_legacy_login_uses_resolved_region(self): + self.add_oidc_device_responses(self.access_token) + self.run_cmd('sso login') + self.assert_used_expected_sso_region('us-east-1') + + def test_legacy_config_rewritten_after_successful_login(self): + self.add_oidc_device_responses(self.access_token) + self.run_cmd('sso login') + with open(self.config_file) as f: + config_content = f.read() + self.assertIn('sso_region = us-east-1', config_content) + + def test_legacy_passes_resolved_url_to_device_authorization(self): + self.add_oidc_device_responses(self.access_token) + self.run_cmd('sso login') + device_auth_call = None + for op, params in self.operations_called: + if op.name == 'StartDeviceAuthorization': + device_auth_call = params + break + self.assertIsNotNone(device_auth_call) + self.assertEqual(device_auth_call['startUrl'], self.resolved_url) + + +class TestLoginWithDirectUrl(BaseSSOTest): + def setUp(self): + super().setUp() + self.start_url = 'https://ssoins-abc.portal.us-west-2.app.aws' + self.sso_region = 'us-west-2' + self.set_config_file_content( + self.get_sso_session_config('direct-session') + ) + self.resolve_patch = mock.patch( + 'awscli.customizations.sso.login.resolve_start_url', + return_value=(self.start_url, 'us-west-2'), + ) + self.mock_resolve = self.resolve_patch.start() + self.is_aws_owned_patch = mock.patch( + 'awscli.customizations.sso.login.is_aws_owned_domain', + return_value=True, + ) + self.mock_is_aws_owned = self.is_aws_owned_patch.start() + + def tearDown(self): + super().tearDown() + self.resolve_patch.stop() + self.is_aws_owned_patch.stop() + + def test_config_not_rewritten_for_direct_url(self): + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + with open(self.config_file) as f: + config_content = f.read() + self.assertIn(f'sso_region={self.sso_region}', config_content) + + def test_login_uses_configured_region(self): + self.add_oidc_auth_code_responses(self.access_token) + self.run_cmd('sso login') + self.assert_used_expected_sso_region('us-west-2') From ebfdd12d54d3b0d5ecd8ee65522ed5d51cb27f97 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 16 Jun 2026 15:20:13 -0400 Subject: [PATCH 6/8] Wire resolver into aws configure sso to skip region prompt for vanity URLs --- .../customizations/configure/sso_commands.py | 49 +++- .../configure/test_sso_resolve.py | 213 ++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 tests/unit/customizations/configure/test_sso_resolve.py diff --git a/awscli/customizations/configure/sso_commands.py b/awscli/customizations/configure/sso_commands.py index e9d80cc548d3..a778e9ee9e58 100644 --- a/awscli/customizations/configure/sso_commands.py +++ b/awscli/customizations/configure/sso_commands.py @@ -26,6 +26,7 @@ import logging import os +from urllib.parse import urlparse import colorama from botocore import UNSIGNED @@ -38,6 +39,10 @@ profile_to_section, ) from awscli.customizations.configure.writer import ConfigFileWriter +from awscli.customizations.sso.resolve import ( + is_aws_owned_domain, + resolve_start_url, +) from awscli.customizations.sso.utils import ( LOGIN_ARGS, BaseSSOCommand, @@ -314,6 +319,9 @@ def _run_main(self, parsed_args, parsed_globals): if parsed_args.no_browser: on_pending_authorization = PrintOnlyHandler() sso_registration_args = self._prompt_for_sso_registration_args() + resolved = getattr(self, '_resolved_start_url', None) + if resolved: + sso_registration_args['resolved_start_url'] = resolved sso_token = self._sso_login( self._session, parsed_globals=parsed_globals, @@ -410,6 +418,25 @@ def _set_sso_session_defaults_from_profile_config(self): def _prompt_for_sso_start_url_and_sso_region(self): start_url = self._sso_session_prompter.prompt_for_sso_start_url() + hostname = urlparse(start_url).hostname + if hostname and not is_aws_owned_domain(hostname): + try: + resolved_url, region = resolve_start_url( + start_url, session=self._session + ) + self._resolved_start_url = resolved_url + self._sso_session_prompter.sso_session_config['sso_region'] = ( + region + ) + return start_url, region + except Exception as e: + logger.debug( + "Failed to resolve vanity URL '%s': %s. " + "Falling back to region prompt.", + start_url, + e, + ) + self._resolved_start_url = None sso_region = self._sso_session_prompter.prompt_for_sso_region() return start_url, sso_region @@ -479,8 +506,26 @@ class ConfigureSSOSessionCommand(BaseSSOConfigurationCommand): def _run_main(self, parsed_args, parsed_globals): super()._run_main(parsed_args, parsed_globals) self._sso_session_prompter.prompt_for_sso_session() - self._sso_session_prompter.prompt_for_sso_start_url() - self._sso_session_prompter.prompt_for_sso_region() + start_url = self._sso_session_prompter.prompt_for_sso_start_url() + hostname = urlparse(start_url).hostname + if hostname and not is_aws_owned_domain(hostname): + try: + resolved_url, region = resolve_start_url( + start_url, session=self._session + ) + self._sso_session_prompter.sso_session_config['sso_region'] = ( + region + ) + except Exception as e: + logger.debug( + "Failed to resolve vanity URL '%s': %s. " + "Falling back to region prompt.", + start_url, + e, + ) + self._sso_session_prompter.prompt_for_sso_region() + else: + self._sso_session_prompter.prompt_for_sso_region() self._sso_session_prompter.prompt_for_sso_registration_scopes() self._write_sso_configuration() self._print_configuration_success() diff --git a/tests/unit/customizations/configure/test_sso_resolve.py b/tests/unit/customizations/configure/test_sso_resolve.py new file mode 100644 index 000000000000..91c0e2ce8aa6 --- /dev/null +++ b/tests/unit/customizations/configure/test_sso_resolve.py @@ -0,0 +1,213 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import pytest + +from awscli.customizations.exceptions import ConfigurationError +from awscli.testutils import mock + + +class TestConfigureSSOVanityUrlResolution: + def _create_command(self): + from awscli.customizations.configure.sso_commands import ( + ConfigureSSOCommand, + ) + + session = mock.Mock() + session.full_config = {'sso_sessions': {}} + cmd = ConfigureSSOCommand(session) + cmd._sso_session_prompter = mock.Mock() + return cmd + + def test_vanity_url_skips_region_prompt(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://aws.mycompany.com' + ) + cmd._sso_session_prompter.sso_session_config = {} + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=False, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + return_value=( + 'https://ssoins-abc.portal.us-east-1.app.aws', + 'us-east-1', + ), + ), + ): + start_url, region = cmd._prompt_for_sso_start_url_and_sso_region() + + assert start_url == 'https://aws.mycompany.com' + assert region == 'us-east-1' + cmd._sso_session_prompter.prompt_for_sso_region.assert_not_called() + + def test_vanity_url_resolution_failure_falls_back_to_prompt(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://aws.mycompany.com' + ) + cmd._sso_session_prompter.prompt_for_sso_region.return_value = ( + 'eu-west-1' + ) + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=False, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + side_effect=ConfigurationError("Failed to resolve"), + ), + ): + start_url, region = cmd._prompt_for_sso_start_url_and_sso_region() + + assert start_url == 'https://aws.mycompany.com' + assert region == 'eu-west-1' + cmd._sso_session_prompter.prompt_for_sso_region.assert_called_once() + + def test_direct_url_prompts_for_region(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://ssoins-abc.portal.us-west-2.app.aws' + ) + cmd._sso_session_prompter.prompt_for_sso_region.return_value = ( + 'us-west-2' + ) + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=True, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + ) as mock_resolve, + ): + start_url, region = cmd._prompt_for_sso_start_url_and_sso_region() + + assert start_url == 'https://ssoins-abc.portal.us-west-2.app.aws' + assert region == 'us-west-2' + cmd._sso_session_prompter.prompt_for_sso_region.assert_called_once() + mock_resolve.assert_not_called() + + def test_vanity_url_persists_resolved_region_in_session_config(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://aws.mycompany.com' + ) + cmd._sso_session_prompter.sso_session_config = {} + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=False, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + return_value=( + 'https://ssoins-abc.portal.us-east-1.app.aws', + 'us-east-1', + ), + ), + ): + cmd._prompt_for_sso_start_url_and_sso_region() + + assert ( + cmd._sso_session_prompter.sso_session_config['sso_region'] + == 'us-east-1' + ) + + +class TestConfigureSSOSessionVanityUrlResolution: + def _create_command(self): + from awscli.customizations.configure.sso_commands import ( + ConfigureSSOSessionCommand, + ) + + session = mock.Mock() + session.full_config = {'sso_sessions': {}} + cmd = ConfigureSSOSessionCommand(session) + cmd._sso_session_prompter = mock.Mock() + cmd._sso_session_prompter.sso_session_config = {} + cmd._init_prompt_toolkit = mock.Mock() + return cmd + + def test_vanity_url_skips_region_prompt(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://aws.mycompany.com' + ) + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=False, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + return_value=( + 'https://ssoins-abc.portal.us-east-1.app.aws', + 'us-east-1', + ), + ), + mock.patch.object(cmd, '_write_sso_configuration'), + mock.patch.object(cmd, '_print_configuration_success'), + ): + cmd._run_main(mock.Mock(), mock.Mock()) + + cmd._sso_session_prompter.prompt_for_sso_region.assert_not_called() + assert ( + cmd._sso_session_prompter.sso_session_config['sso_region'] + == 'us-east-1' + ) + + def test_vanity_url_failure_falls_back_to_prompt(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://aws.mycompany.com' + ) + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=False, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + side_effect=ConfigurationError("Failed to resolve"), + ), + mock.patch.object(cmd, '_write_sso_configuration'), + mock.patch.object(cmd, '_print_configuration_success'), + ): + cmd._run_main(mock.Mock(), mock.Mock()) + + cmd._sso_session_prompter.prompt_for_sso_region.assert_called_once() + + def test_direct_url_prompts_for_region(self): + cmd = self._create_command() + cmd._sso_session_prompter.prompt_for_sso_start_url.return_value = ( + 'https://ssoins-abc.portal.us-west-2.app.aws' + ) + with ( + mock.patch( + 'awscli.customizations.configure.sso_commands.is_aws_owned_domain', + return_value=True, + ), + mock.patch( + 'awscli.customizations.configure.sso_commands.resolve_start_url', + ) as mock_resolve, + mock.patch.object(cmd, '_write_sso_configuration'), + mock.patch.object(cmd, '_print_configuration_success'), + ): + cmd._run_main(mock.Mock(), mock.Mock()) + + cmd._sso_session_prompter.prompt_for_sso_region.assert_called_once() + mock_resolve.assert_not_called() From bf6d38098b5622e0243ee332fe807637d1bf1c7a Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Thu, 18 Jun 2026 16:43:37 -0400 Subject: [PATCH 7/8] Remove stale test file consolidated into test_login.py --- tests/functional/sso/test_login_resolve.py | 172 --------------------- 1 file changed, 172 deletions(-) delete mode 100644 tests/functional/sso/test_login_resolve.py diff --git a/tests/functional/sso/test_login_resolve.py b/tests/functional/sso/test_login_resolve.py deleted file mode 100644 index 338e3d5f60e8..000000000000 --- a/tests/functional/sso/test_login_resolve.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). You -# may not use this file except in compliance with the License. A copy of -# the License is located at -# -# http://aws.amazon.com/apache2.0/ -# -# or in the "license" file accompanying this file. This file is -# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -# ANY KIND, either express or implied. See the License for the specific -# language governing permissions and limitations under the License. -import os - -from awscli.testutils import mock -from tests.functional.sso import BaseSSOTest - - -class TestLoginWithVanityUrl(BaseSSOTest): - def setUp(self): - super().setUp() - self.vanity_url = 'https://aws.mycompany.com' - self.resolved_url = 'https://ssoins-abc.portal.us-east-1.app.aws' - self.start_url = self.vanity_url - self.sso_region = 'us-west-2' - self.set_config_file_content( - self.get_sso_session_config('vanity-session') - ) - self.resolve_patch = mock.patch( - 'awscli.customizations.sso.login.resolve_start_url', - return_value=(self.resolved_url, 'us-east-1'), - ) - self.mock_resolve = self.resolve_patch.start() - self.is_aws_owned_patch = mock.patch( - 'awscli.customizations.sso.login.is_aws_owned_domain', - return_value=False, - ) - self.mock_is_aws_owned = self.is_aws_owned_patch.start() - - def tearDown(self): - super().tearDown() - self.resolve_patch.stop() - self.is_aws_owned_patch.stop() - - def test_login_uses_resolved_region(self): - self.add_oidc_auth_code_responses(self.access_token) - self.run_cmd('sso login') - self.assert_used_expected_sso_region('us-east-1') - - def test_login_passes_resolved_url_to_register_client(self): - self.add_oidc_auth_code_responses(self.access_token) - self.run_cmd('sso login') - register_call = self.operations_called[0] - self.assertEqual(register_call[0].name, 'RegisterClient') - self.assertEqual(register_call[1]['issuerUrl'], self.resolved_url) - - def test_config_rewritten_after_successful_login(self): - self.add_oidc_auth_code_responses(self.access_token) - self.run_cmd('sso login') - with open(self.config_file) as f: - config_content = f.read() - self.assertIn('sso_region = us-east-1', config_content) - - def test_config_not_rewritten_on_login_failure(self): - self.parsed_responses = [ - { - 'Error': { - 'Code': 'InvalidRequestException', - 'Message': 'Invalid start url provided', - } - } - ] - self.run_cmd('sso login', expected_rc=254) - with open(self.config_file) as f: - config_content = f.read() - self.assertIn(f'sso_region={self.sso_region}', config_content) - - def test_config_not_rewritten_when_region_matches(self): - self.sso_region = 'us-east-1' - self.set_config_file_content( - self.get_sso_session_config('vanity-session') - ) - self.add_oidc_auth_code_responses(self.access_token) - self.run_cmd('sso login') - with open(self.config_file) as f: - config_content = f.read() - self.assertIn('sso_region=us-east-1', config_content) - - -class TestLoginWithVanityUrlLegacy(BaseSSOTest): - def setUp(self): - super().setUp() - self.vanity_url = 'https://aws.mycompany.com' - self.resolved_url = 'https://ssoins-abc.portal.us-east-1.app.aws' - self.start_url = self.vanity_url - self.sso_region = 'us-west-2' - self.set_config_file_content(self.get_legacy_config()) - self.resolve_patch = mock.patch( - 'awscli.customizations.sso.login.resolve_start_url', - return_value=(self.resolved_url, 'us-east-1'), - ) - self.mock_resolve = self.resolve_patch.start() - self.is_aws_owned_patch = mock.patch( - 'awscli.customizations.sso.login.is_aws_owned_domain', - return_value=False, - ) - self.mock_is_aws_owned = self.is_aws_owned_patch.start() - - def tearDown(self): - super().tearDown() - self.resolve_patch.stop() - self.is_aws_owned_patch.stop() - - def test_legacy_login_uses_resolved_region(self): - self.add_oidc_device_responses(self.access_token) - self.run_cmd('sso login') - self.assert_used_expected_sso_region('us-east-1') - - def test_legacy_config_rewritten_after_successful_login(self): - self.add_oidc_device_responses(self.access_token) - self.run_cmd('sso login') - with open(self.config_file) as f: - config_content = f.read() - self.assertIn('sso_region = us-east-1', config_content) - - def test_legacy_passes_resolved_url_to_device_authorization(self): - self.add_oidc_device_responses(self.access_token) - self.run_cmd('sso login') - device_auth_call = None - for op, params in self.operations_called: - if op.name == 'StartDeviceAuthorization': - device_auth_call = params - break - self.assertIsNotNone(device_auth_call) - self.assertEqual(device_auth_call['startUrl'], self.resolved_url) - - -class TestLoginWithDirectUrl(BaseSSOTest): - def setUp(self): - super().setUp() - self.start_url = 'https://ssoins-abc.portal.us-west-2.app.aws' - self.sso_region = 'us-west-2' - self.set_config_file_content( - self.get_sso_session_config('direct-session') - ) - self.resolve_patch = mock.patch( - 'awscli.customizations.sso.login.resolve_start_url', - return_value=(self.start_url, 'us-west-2'), - ) - self.mock_resolve = self.resolve_patch.start() - self.is_aws_owned_patch = mock.patch( - 'awscli.customizations.sso.login.is_aws_owned_domain', - return_value=True, - ) - self.mock_is_aws_owned = self.is_aws_owned_patch.start() - - def tearDown(self): - super().tearDown() - self.resolve_patch.stop() - self.is_aws_owned_patch.stop() - - def test_config_not_rewritten_for_direct_url(self): - self.add_oidc_auth_code_responses(self.access_token) - self.run_cmd('sso login') - with open(self.config_file) as f: - config_content = f.read() - self.assertIn(f'sso_region={self.sso_region}', config_content) - - def test_login_uses_configured_region(self): - self.add_oidc_auth_code_responses(self.access_token) - self.run_cmd('sso login') - self.assert_used_expected_sso_region('us-west-2') From b471bb8736bf96187b9e62dedf2c7879f6ac994f Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Thu, 18 Jun 2026 16:58:17 -0400 Subject: [PATCH 8/8] Refactor: return resolved_url from method instead of storing on self --- .../customizations/configure/sso_commands.py | 27 +++++++++++-------- .../configure/test_sso_resolve.py | 15 ++++++++--- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/awscli/customizations/configure/sso_commands.py b/awscli/customizations/configure/sso_commands.py index a778e9ee9e58..32a09ffe4412 100644 --- a/awscli/customizations/configure/sso_commands.py +++ b/awscli/customizations/configure/sso_commands.py @@ -319,9 +319,6 @@ def _run_main(self, parsed_args, parsed_globals): if parsed_args.no_browser: on_pending_authorization = PrintOnlyHandler() sso_registration_args = self._prompt_for_sso_registration_args() - resolved = getattr(self, '_resolved_start_url', None) - if resolved: - sso_registration_args['resolved_start_url'] = resolved sso_token = self._sso_login( self._session, parsed_globals=parsed_globals, @@ -372,8 +369,13 @@ def _prompt_for_sso_registration_args(self): def _prompt_for_registration_args_with_legacy_format(self): self._store_sso_session_prompter_answers_to_profile_config() self._set_sso_session_defaults_from_profile_config() - start_url, sso_region = self._prompt_for_sso_start_url_and_sso_region() - return {'start_url': start_url, 'sso_region': sso_region} + start_url, sso_region, resolved_url = ( + self._prompt_for_sso_start_url_and_sso_region() + ) + args = {'start_url': start_url, 'sso_region': sso_region} + if resolved_url: + args['resolved_start_url'] = resolved_url + return args def _get_sso_registration_args_from_sso_config(self, sso_session): sso_config = self._get_sso_session_config(sso_session) @@ -386,17 +388,22 @@ def _get_sso_registration_args_from_sso_config(self, sso_session): def _prompt_for_registration_args_for_new_sso_session(self, sso_session): self._set_sso_session_defaults_from_profile_config() - start_url, sso_region = self._prompt_for_sso_start_url_and_sso_region() + start_url, sso_region, resolved_url = ( + self._prompt_for_sso_start_url_and_sso_region() + ) scopes = ( self._sso_session_prompter.prompt_for_sso_registration_scopes() ) - return { + args = { 'session_name': sso_session, 'start_url': start_url, 'sso_region': sso_region, 'registration_scopes': scopes, 'force_refresh': True, } + if resolved_url: + args['resolved_start_url'] = resolved_url + return args def _store_sso_session_prompter_answers_to_profile_config(self): self._sso_session_prompter.sso_session_config = ( @@ -424,11 +431,10 @@ def _prompt_for_sso_start_url_and_sso_region(self): resolved_url, region = resolve_start_url( start_url, session=self._session ) - self._resolved_start_url = resolved_url self._sso_session_prompter.sso_session_config['sso_region'] = ( region ) - return start_url, region + return start_url, region, resolved_url except Exception as e: logger.debug( "Failed to resolve vanity URL '%s': %s. " @@ -436,9 +442,8 @@ def _prompt_for_sso_start_url_and_sso_region(self): start_url, e, ) - self._resolved_start_url = None sso_region = self._sso_session_prompter.prompt_for_sso_region() - return start_url, sso_region + return start_url, sso_region, None def _warn_configuring_using_legacy_format(self): uni_print( diff --git a/tests/unit/customizations/configure/test_sso_resolve.py b/tests/unit/customizations/configure/test_sso_resolve.py index 91c0e2ce8aa6..1cc70503eca3 100644 --- a/tests/unit/customizations/configure/test_sso_resolve.py +++ b/tests/unit/customizations/configure/test_sso_resolve.py @@ -47,10 +47,13 @@ def test_vanity_url_skips_region_prompt(self): ), ), ): - start_url, region = cmd._prompt_for_sso_start_url_and_sso_region() + start_url, region, resolved_url = ( + cmd._prompt_for_sso_start_url_and_sso_region() + ) assert start_url == 'https://aws.mycompany.com' assert region == 'us-east-1' + assert resolved_url == 'https://ssoins-abc.portal.us-east-1.app.aws' cmd._sso_session_prompter.prompt_for_sso_region.assert_not_called() def test_vanity_url_resolution_failure_falls_back_to_prompt(self): @@ -71,10 +74,13 @@ def test_vanity_url_resolution_failure_falls_back_to_prompt(self): side_effect=ConfigurationError("Failed to resolve"), ), ): - start_url, region = cmd._prompt_for_sso_start_url_and_sso_region() + start_url, region, resolved_url = ( + cmd._prompt_for_sso_start_url_and_sso_region() + ) assert start_url == 'https://aws.mycompany.com' assert region == 'eu-west-1' + assert resolved_url is None cmd._sso_session_prompter.prompt_for_sso_region.assert_called_once() def test_direct_url_prompts_for_region(self): @@ -94,10 +100,13 @@ def test_direct_url_prompts_for_region(self): 'awscli.customizations.configure.sso_commands.resolve_start_url', ) as mock_resolve, ): - start_url, region = cmd._prompt_for_sso_start_url_and_sso_region() + start_url, region, resolved_url = ( + cmd._prompt_for_sso_start_url_and_sso_region() + ) assert start_url == 'https://ssoins-abc.portal.us-west-2.app.aws' assert region == 'us-west-2' + assert resolved_url is None cmd._sso_session_prompter.prompt_for_sso_region.assert_called_once() mock_resolve.assert_not_called()