diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 13f47ed417c..dee887e741f 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -826,7 +826,30 @@ def find_using_common_tenant(self, username, credential=None): client = self._create_subscription_client(credential) # https://learn.microsoft.com/en-us/rest/api/resources/tenants/list - tenants = client.tenants.list() + from azure.core.exceptions import DecodeError, HttpResponseError + from azure.cli.core.azclierror import AzureResponseError + try: + # Eagerly materialise tenants here so any HTTP errors (e.g. firewall blocking the + # request with a non-JSON response) are caught below before iteration begins. + # Tenant counts are typically small so materialising up-front is not a concern. + tenants = list(client.tenants.list()) + except (DecodeError, HttpResponseError) as ex: + if isinstance(ex, DecodeError): + raise AzureResponseError( + "Failed to retrieve tenants. The response from the server could not be parsed. " + "This may be caused by a network firewall or proxy returning an unexpected response. " + "Please check your network settings and try again, or use " + "'az login --tenant TENANT_ID' to log in to a specific tenant." + ) from ex + # HttpResponseError (but not DecodeError) + if ex.status_code == 403: + raise AzureResponseError( + "Failed to retrieve tenants. The request was blocked (HTTP 403 Forbidden). " + "This may be caused by a network firewall, proxy, or Conditional Access policy. " + "Please check your network settings and try again, or use " + "'az login --tenant TENANT_ID' to log in to a specific tenant." + ) from ex + raise for t in tenants: tenant_id = t.tenant_id diff --git a/src/azure-cli-core/azure/cli/core/tests/test_profile.py b/src/azure-cli-core/azure/cli/core/tests/test_profile.py index d62464ca5d3..38cd20baee9 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_profile.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_profile.py @@ -684,6 +684,62 @@ def test_login_no_subscription_raises_error(self, can_launch_browser_mock, profile.login(True, None, None, False, None, use_device_code=False, allow_no_subscriptions=False) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_auth_code', autospec=True) + @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) + def test_login_tenant_list_403_raises_friendly_error(self, can_launch_browser_mock, + login_with_auth_code_mock, get_user_credential_mock, + create_subscription_client_mock): + """When listing tenants returns 403 (e.g. network block), a friendly AzureResponseError is raised.""" + from azure.core.exceptions import HttpResponseError + from azure.cli.core.azclierror import AzureResponseError + login_with_auth_code_mock.return_value = self.user_identity_mock + + cli = DummyCli() + mock_subscription_client = mock.MagicMock() + http_response_mock = mock.MagicMock() + http_response_mock.status_code = 403 + http_error = HttpResponseError(response=http_response_mock) + mock_subscription_client.tenants.list.side_effect = http_error + create_subscription_client_mock.return_value = mock_subscription_client + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + with self.assertRaises(AzureResponseError) as cm: + profile.login(True, None, None, False, None, use_device_code=False, allow_no_subscriptions=False) + self.assertIn("403", str(cm.exception)) + + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) + @mock.patch('azure.cli.core.auth.identity.Identity.login_with_auth_code', autospec=True) + @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) + def test_login_tenant_list_decode_error_raises_friendly_error(self, can_launch_browser_mock, + login_with_auth_code_mock, + get_user_credential_mock, + create_subscription_client_mock): + """When listing tenants returns a non-JSON response (e.g. HTML 403 block page), a friendly error is raised.""" + from azure.core.exceptions import DecodeError + from azure.cli.core.azclierror import AzureResponseError + login_with_auth_code_mock.return_value = self.user_identity_mock + + cli = DummyCli() + mock_subscription_client = mock.MagicMock() + mock_subscription_client.tenants.list.side_effect = DecodeError( + message="JSON is invalid: Expecting value: line 1 column 1 (char 0)", + response=mock.MagicMock(), + error=None + ) + create_subscription_client_mock.return_value = mock_subscription_client + + storage_mock = {'subscriptions': None} + profile = Profile(cli_ctx=cli, storage=storage_mock) + + with self.assertRaises(AzureResponseError) as cm: + profile.login(True, None, None, False, None, use_device_code=False, allow_no_subscriptions=False) + self.assertIn("could not be parsed", str(cm.exception)) + @mock.patch('azure.cli.core._profile.SubscriptionFinder._create_subscription_client', autospec=True) @mock.patch('azure.cli.core.auth.identity.Identity.get_user_credential', autospec=True) @mock.patch('azure.cli.core.auth.identity.Identity.login_with_auth_code', autospec=True)