Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions src/azure-cli-core/azure/cli/core/_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
116 changes: 116 additions & 0 deletions src/azure-cli-core/azure/cli/core/tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading