diff --git a/src/azure-cli-core/azure/cli/core/_profile.py b/src/azure-cli-core/azure/cli/core/_profile.py index 13f47ed417c..b849e8a3ed5 100644 --- a/src/azure-cli-core/azure/cli/core/_profile.py +++ b/src/azure-cli-core/azure/cli/core/_profile.py @@ -8,8 +8,10 @@ from copy import deepcopy from enum import Enum +from azure.core.exceptions import DecodeError, HttpResponseError + from azure.cli.core._session import ACCOUNT -from azure.cli.core.azclierror import AuthenticationError +from azure.cli.core.azclierror import AuthenticationError, AzureResponseError from azure.cli.core.cloud import get_active_cloud, set_cloud_subscription from azure.cli.core.auth.credential_adaptor import CredentialAdaptor from azure.cli.core.util import in_cloud_console, can_launch_browser, is_github_codespaces @@ -808,6 +810,25 @@ def credential_factory(id_type, id_value): raise ValueError("Unrecognized managed identity ID type '{}'".format(id_type)) +def _raise_friendly_error(ex, resource_name): + # Translate blocked-transport errors (HTTP 403 or non-JSON body) into an actionable + # AzureResponseError. Any other exception is re-raised unchanged. resource_name is + # interpolated into the error message (e.g. "tenants", "subscriptions"). + if isinstance(ex, DecodeError): + raise AzureResponseError( + "Failed to retrieve {name}. 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.".format(name=resource_name) + ) from ex + if isinstance(ex, HttpResponseError) and ex.status_code == 403: + raise AzureResponseError( + "Failed to retrieve {name}. 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.".format(name=resource_name) + ) from ex + raise ex + + class SubscriptionFinder: # An ARM client. It finds subscriptions for a user or service principal. It shouldn't do any # authentication work, but only find subscriptions @@ -826,7 +847,11 @@ 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() + try: + # list(...) forces the pager's HTTP call to fire inside this try (it's lazy otherwise). + tenants = list(client.tenants.list()) + except (DecodeError, HttpResponseError) as ex: + _raise_friendly_error(ex, "tenants") for t in tenants: tenant_id = t.tenant_id @@ -898,7 +923,10 @@ def find_using_specific_tenant(self, tenant, credential, tenant_id_description=N """ client = self._create_subscription_client(credential) # https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list - subscriptions = client.subscriptions.list() + try: + subscriptions = list(client.subscriptions.list()) + except (DecodeError, HttpResponseError) as ex: + _raise_friendly_error(ex, "subscriptions") all_subscriptions = [] for s in subscriptions: _attach_token_tenant(s, tenant, tenant_id_description=tenant_id_description) 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..422f140aa92 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,122 @@ 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) + @mock.patch('azure.cli.core._profile.can_launch_browser', autospec=True, return_value=True) + def test_login_specific_tenant_subscription_list_403_raises_friendly_error( + self, can_launch_browser_mock, login_with_auth_code_mock, get_user_credential_mock, + create_subscription_client_mock): + """When ``az login --tenant`` hits a 403 listing subscriptions (e.g. blocked by a firewall), + a friendly ``AzureResponseError`` is raised instead of a raw ``HttpResponseError``.""" + 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 + mock_subscription_client.subscriptions.list.side_effect = HttpResponseError(response=http_response_mock) + 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, self.tenant_id, + use_device_code=False, allow_no_subscriptions=False) + self.assertIn("403", str(cm.exception)) + self.assertIn("subscriptions", 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_specific_tenant_subscription_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 ``az login --tenant`` gets a non-JSON body listing subscriptions (e.g. HTML block + page), a friendly ``AzureResponseError`` is raised instead of ``DecodeError``.""" + 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.subscriptions.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, self.tenant_id, + use_device_code=False, allow_no_subscriptions=False) + self.assertIn("could not be parsed", str(cm.exception)) + self.assertIn("subscriptions", 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)