diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index 20fb7c1f6442..23ec24926dbf 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -4,6 +4,10 @@ - Correctly parse token expiration time on Windows App Service ([#9393](https://github.com/Azure/azure-sdk-for-python/issues/9393)) +- Credentials raise `CredentialUnavailableError` when they can't attempt to +authenticate due to missing data or state +([#9372](https://github.com/Azure/azure-sdk-for-python/pull/9372)) + ## 1.2.0 (2020-01-14) diff --git a/sdk/identity/azure-identity/README.md b/sdk/identity/azure-identity/README.md index 8be7c426e2ef..9ae2e458f727 100644 --- a/sdk/identity/azure-identity/README.md +++ b/sdk/identity/azure-identity/README.md @@ -189,11 +189,14 @@ client = SecretClient("https://my-vault.vault.azure.net", credential) ## Chaining credentials: [ChainedTokenCredential][chain_cred_ref] links multiple credential instances -to be tried sequentially when authenticating. The following example demonstrates -creating a credential which will attempt to authenticate using managed identity, -and fall back to a service principal if a managed identity is unavailable. This -example uses the `EventHubClient` from the [azure-eventhub][azure_eventhub] -client library. +to be tried sequentially when authenticating. It will try each chained +credential in turn until one provides a token or fails to authenticate due to +an error. + +The following example demonstrates creating a credential which will attempt to +authenticate using managed identity, and fall back to a service principal when +managed identity is unavailable. This example uses the `EventHubClient` from +the [azure-eventhub][azure_eventhub] client library. ```py from azure.eventhub import EventHubClient @@ -202,8 +205,8 @@ from azure.identity import ChainedTokenCredential, ClientSecretCredential, Manag managed_identity = ManagedIdentityCredential() service_principal = ClientSecretCredential(tenant_id, client_id, client_secret) -# when an access token is needed, the chain will try each -# credential in order, stopping when one provides a token +# when an access token is needed, the chain will try each credential in order, +# stopping when one provides a token or fails to authenticate due to an error credential_chain = ChainedTokenCredential(managed_identity, service_principal) # the ChainedTokenCredential can be used anywhere a credential is required @@ -252,6 +255,11 @@ client = SecretClient("https://my-vault.vault.azure.net", default_credential) # Troubleshooting ## General +Credentials raise `CredentialUnavailableError` when they're unable to attempt +authentication because they lack required data or state. For example, +[EnvironmentCredential][environment_cred_ref] will raise this exception when +[its configuration](#environment-variables "its configuration") is incomplete. + Credentials raise `azure.core.exceptions.ClientAuthenticationError` when they fail to authenticate. `ClientAuthenticationError` has a `message` attribute which describes why authentication failed. When raised by diff --git a/sdk/identity/azure-identity/azure/identity/__init__.py b/sdk/identity/azure-identity/azure/identity/__init__.py index d7261c0bf536..7648a37ffa19 100644 --- a/sdk/identity/azure-identity/azure/identity/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/__init__.py @@ -4,6 +4,7 @@ # ------------------------------------ """Credentials for Azure SDK clients.""" +from ._exceptions import CredentialUnavailableError from ._constants import KnownAuthorities from ._credentials import ( AuthorizationCodeCredential, @@ -25,6 +26,7 @@ "CertificateCredential", "ChainedTokenCredential", "ClientSecretCredential", + "CredentialUnavailableError", "DefaultAzureCredential", "DeviceCodeCredential", "EnvironmentCredential", @@ -36,4 +38,5 @@ ] from ._version import VERSION + __version__ = VERSION diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/browser.py b/sdk/identity/azure-identity/azure/identity/_credentials/browser.py index 7f80e191987a..28e60f56429c 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/browser.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/browser.py @@ -10,6 +10,7 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError +from .. import CredentialUnavailableError from .._constants import AZURE_CLI_CLIENT_ID from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions @@ -38,7 +39,6 @@ class InteractiveBrowserCredential(PublicClientCredential): :keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If unspecified, the Azure CLI's ID will be used. :keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes). - """ def __init__(self, **kwargs): @@ -60,7 +60,9 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: the credential is unable to start an HTTP server on + localhost, or is unable to open a browser + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed """ return self._get_token_from_cache(scopes, **kwargs) or self._get_token_by_auth_code(scopes, **kwargs) @@ -88,7 +90,7 @@ def _get_token_by_auth_code(self, scopes, **kwargs): continue # keep looking for an open port if not redirect_uri: - raise ClientAuthenticationError(message="Couldn't start an HTTP server on localhost") + raise CredentialUnavailableError(message="Couldn't start an HTTP server on localhost") # get the url the user must visit to authenticate scopes = list(scopes) # type: ignore @@ -100,7 +102,7 @@ def _get_token_by_auth_code(self, scopes, **kwargs): # open browser to that url if not webbrowser.open(auth_url): - raise ClientAuthenticationError(message="Failed to open a browser") + raise CredentialUnavailableError(message="Failed to open a browser") # block until the server times out or receives the post-authentication redirect response = server.wait_for_redirect() diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/chained.py b/sdk/identity/azure-identity/azure/identity/_credentials/chained.py index 6c97ac8b6865..c6242c831b5d 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/chained.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/chained.py @@ -4,6 +4,8 @@ # ------------------------------------ from azure.core.exceptions import ClientAuthenticationError +from .. import CredentialUnavailableError + try: from typing import TYPE_CHECKING except ImportError: @@ -51,15 +53,19 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. :param str scopes: desired scopes for the token - :raises ~azure.core.exceptions.ClientAuthenticationError: when no credential in the chain provides a token + :raises ~azure.core.exceptions.ClientAuthenticationError: no credential in the chain provided a token """ history = [] for credential in self.credentials: try: return credential.get_token(*scopes, **kwargs) - except ClientAuthenticationError as ex: + except CredentialUnavailableError as ex: + # credential didn't attempt authentication because it lacks required data or state -> continue history.append((credential, ex.message)) except Exception as ex: # pylint: disable=broad-except + # credential failed to authenticate, or something unexpectedly raised -> break history.append((credential, str(ex))) + break + error_message = _get_error_message(history) raise ClientAuthenticationError(message=error_message) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/environment.py b/sdk/identity/azure-identity/azure/identity/_credentials/environment.py index 6094f0520aa9..7508674f6847 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/environment.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/environment.py @@ -4,7 +4,7 @@ # ------------------------------------ import os -from azure.core.exceptions import ClientAuthenticationError +from .. import CredentialUnavailableError from .._constants import EnvironmentVariables from .client_credential import CertificateCredential, ClientSecretCredential from .user import UsernamePasswordCredential @@ -22,7 +22,27 @@ EnvironmentCredentialTypes = Union["CertificateCredential", "ClientSecretCredential", "UsernamePasswordCredential"] -class EnvironmentCredential: +def get_credential_unavailable_message(): + # type: () -> str + message = ( + "Incomplete environment configuration. See " + + "https://aka.ms/python-sdk-identity#environment-variables for expected environment variables" + ) + + all_variables = { + _ + for _ in EnvironmentVariables.CLIENT_SECRET_VARS + + EnvironmentVariables.CERT_VARS + + EnvironmentVariables.USERNAME_PASSWORD_VARS + } + set_variables = ", ".join(v for v in all_variables if v in os.environ) + if set_variables: + message += ". Currently set variables: {}".format(set_variables) + + return message + + +class EnvironmentCredential(object): """A credential configured by environment variables. This credential is capable of authenticating as a service principal using a client secret or a certificate, or as @@ -51,6 +71,7 @@ class EnvironmentCredential: def __init__(self, **kwargs): # type: (Mapping[str, Any]) -> None self._credential = None # type: Optional[EnvironmentCredentialTypes] + self._unavailable_message = "" if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS): self._credential = ClientSecretCredential( @@ -75,6 +96,9 @@ def __init__(self, **kwargs): **kwargs ) + if not self._credential: + self._unavailable_message = get_credential_unavailable_message() + def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument # type: (*str, **Any) -> AccessToken """Request an access token for `scopes`. @@ -83,8 +107,8 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: environment variable configuration is incomplete """ if not self._credential: - raise ClientAuthenticationError(message="Incomplete environment configuration") + raise CredentialUnavailableError(message=self._unavailable_message) return self._credential.get_token(*scopes, **kwargs) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/managed_identity.py b/sdk/identity/azure-identity/azure/identity/_credentials/managed_identity.py index e9ece83af541..6d8031ad57d9 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/managed_identity.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/managed_identity.py @@ -6,7 +6,7 @@ from azure.core.configuration import Configuration from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError, HttpResponseError +from azure.core.exceptions import HttpResponseError from azure.core.pipeline.policies import ( ContentDecodePolicy, DistributedTracingPolicy, @@ -17,6 +17,7 @@ UserAgentPolicy, ) +from .. import CredentialUnavailableError from .._authn_client import AuthnClient from .._constants import Endpoints, EnvironmentVariables from .._internal.user_agent import USER_AGENT @@ -59,7 +60,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument,no-sel :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: managed identity isn't available in the hosting environment """ return AccessToken() @@ -132,7 +133,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: the IMDS endpoint is unreachable """ if self._endpoint_available is None: # Lacking another way to determine whether the IMDS endpoint is listening, @@ -149,7 +150,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument self._endpoint_available = False if not self._endpoint_available: - raise ClientAuthenticationError(message="IMDS endpoint unavailable") + raise CredentialUnavailableError(message="IMDS endpoint unavailable") if len(scopes) != 1: raise ValueError("this credential supports one scope per request") @@ -184,11 +185,11 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: the MSI endpoint is unavailable """ if not self._endpoint: - raise ClientAuthenticationError(message="MSI endpoint unavailable") + raise CredentialUnavailableError(message="MSI endpoint unavailable") if len(scopes) != 1: raise ValueError("this credential supports only one scope per request") diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/shared_cache.py b/sdk/identity/azure-identity/azure/identity/_credentials/shared_cache.py index e63566835301..e0d5755c5e46 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/shared_cache.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/shared_cache.py @@ -2,8 +2,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ - -from azure.core.exceptions import ClientAuthenticationError +from .. import CredentialUnavailableError from .._constants import AZURE_CLI_CLIENT_ID from .._internal import AadClient, wrap_exceptions from .._internal.shared_token_cache import NO_TOKEN, SharedTokenCacheBase @@ -45,13 +44,13 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises: - :class:`azure.core.exceptions.ClientAuthenticationError` when the cache is unavailable or no access token - can be acquired from it + :raises ~azure.identity.CredentialUnavailableError: the cache is unavailable or contains insufficient user + information + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed """ if not self._client: - raise ClientAuthenticationError(message="Shared token cache unavailable") + raise CredentialUnavailableError(message="Shared token cache unavailable") account = self._get_account(self._username, self._tenant_id) @@ -60,7 +59,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument token = self._client.obtain_token_by_refresh_token(refresh_token, scopes) return token - raise ClientAuthenticationError(message=NO_TOKEN.format(account.get("username"))) + raise CredentialUnavailableError(message=NO_TOKEN.format(account.get("username"))) def _get_auth_client(self, **kwargs): # type: (**Any) -> AadClientBase diff --git a/sdk/identity/azure-identity/azure/identity/_exceptions.py b/sdk/identity/azure-identity/azure/identity/_exceptions.py new file mode 100644 index 000000000000..22802306976f --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_exceptions.py @@ -0,0 +1,9 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +from azure.core.exceptions import ClientAuthenticationError + + +class CredentialUnavailableError(ClientAuthenticationError): + """The credential did not attempt to authenticate because required data or state is unavailable.""" diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/chained.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/chained.py index 155d02860763..8b5bf870f88b 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/chained.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/chained.py @@ -7,6 +7,7 @@ from azure.core.exceptions import ClientAuthenticationError from .base import AsyncCredentialBase +from ... import CredentialUnavailableError from ..._credentials.chained import _get_error_message if TYPE_CHECKING: @@ -44,15 +45,19 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken": .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. :param str scopes: desired scopes for the token - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.core.exceptions.ClientAuthenticationError: no credential in the chain provided a token """ history = [] for credential in self.credentials: try: return await credential.get_token(*scopes, **kwargs) - except ClientAuthenticationError as ex: + except CredentialUnavailableError as ex: + # credential didn't attempt authentication because it lacks required data or state -> continue history.append((credential, ex.message)) except Exception as ex: # pylint: disable=broad-except + # credential failed to authenticate, or something unexpectedly raised -> break history.append((credential, str(ex))) + break + error_message = _get_error_message(history) raise ClientAuthenticationError(message=error_message) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/environment.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/environment.py index 33f420257a71..21bbcf5b521c 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/environment.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/environment.py @@ -5,8 +5,9 @@ import os from typing import TYPE_CHECKING -from azure.core.exceptions import ClientAuthenticationError +from ... import CredentialUnavailableError from ..._constants import EnvironmentVariables +from ..._credentials.environment import get_credential_unavailable_message from .client_credential import CertificateCredential, ClientSecretCredential from .base import AsyncCredentialBase @@ -35,6 +36,7 @@ class EnvironmentCredential(AsyncCredentialBase): def __init__(self, **kwargs: "Any") -> None: self._credential = None # type: Optional[Union[CertificateCredential, ClientSecretCredential]] + self._unavailable_message = "" if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS): self._credential = ClientSecretCredential( @@ -51,6 +53,9 @@ def __init__(self, **kwargs: "Any") -> None: **kwargs ) + if not self._credential: + self._unavailable_message = get_credential_unavailable_message() + async def __aenter__(self): if self._credential: await self._credential.__aenter__() @@ -69,8 +74,8 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken": :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: environment variable configuration is incomplete """ if not self._credential: - raise ClientAuthenticationError(message="Incomplete environment configuration") + raise CredentialUnavailableError(message=self._unavailable_message) return await self._credential.get_token(*scopes, **kwargs) diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/managed_identity.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/managed_identity.py index 697b5c07086e..03fa1bf814ac 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/managed_identity.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/managed_identity.py @@ -7,12 +7,13 @@ from typing import TYPE_CHECKING from azure.core.credentials import AccessToken -from azure.core.exceptions import ClientAuthenticationError, HttpResponseError +from azure.core.exceptions import HttpResponseError from azure.core.pipeline.policies import AsyncRetryPolicy from azure.identity._credentials.managed_identity import _ManagedIdentityBase from .base import AsyncCredentialBase from .._authn_client import AsyncAuthnClient +from ... import CredentialUnavailableError from ..._constants import Endpoints, EnvironmentVariables if TYPE_CHECKING: @@ -55,7 +56,7 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken": # py :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: managed identity isn't available in the hosting environment """ return AccessToken() @@ -98,7 +99,7 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> AccessToken: # pyli :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: the IMDS endpoint is unreachable """ if self._endpoint_available is None: # Lacking another way to determine whether the IMDS endpoint is listening, @@ -107,16 +108,15 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> AccessToken: # pyli try: await self._client.request_token(scopes, method="GET", connection_timeout=0.3, retry_total=0) self._endpoint_available = True - except (ClientAuthenticationError, HttpResponseError): - # received a response a pipeline policy choked on (HttpResponseError) - # or that couldn't be deserialized by AuthnClient (AuthenticationError) + except HttpResponseError: + # received a response, choked on it self._endpoint_available = True except Exception: # pylint:disable=broad-except # if anything else was raised, assume the endpoint is unavailable self._endpoint_available = False if not self._endpoint_available: - raise ClientAuthenticationError(message="IMDS endpoint unavailable") + raise CredentialUnavailableError(message="IMDS endpoint unavailable") if len(scopes) != 1: raise ValueError("this credential supports one scope per request") @@ -149,10 +149,10 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> AccessToken: # pyli :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: + :raises ~azure.identity.CredentialUnavailableError: the MSI endpoint is unavailable """ if not self._endpoint: - raise ClientAuthenticationError(message="MSI endpoint unavailable") + raise CredentialUnavailableError(message="MSI endpoint unavailable") if len(scopes) != 1: raise ValueError("this credential supports only one scope per request") diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/shared_cache.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/shared_cache.py index 46c518df6541..c84aaac27adc 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/shared_cache.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/shared_cache.py @@ -3,8 +3,8 @@ # Licensed under the MIT License. # ------------------------------------ from typing import TYPE_CHECKING -from azure.core.exceptions import ClientAuthenticationError +from ... import CredentialUnavailableError from ..._constants import AZURE_CLI_CLIENT_ID from ..._internal.shared_token_cache import NO_TOKEN, SharedTokenCacheBase from .._internal.aad_client import AadClient @@ -23,6 +23,12 @@ class SharedTokenCacheCredential(SharedTokenCacheBase, AsyncCredentialBase): :param str username: Username (typically an email address) of the user to authenticate as. This is required because the local cache may contain tokens for multiple identities. + + :keyword str authority: Authority of an Azure Active Directory endpoint, for example 'login.microsoftonline.com', + the authority for Azure Public Cloud (which is the default). :class:`~azure.identity.KnownAuthorities` + defines authorities for other clouds. + :keyword str tenant_id: an Azure Active Directory tenant ID. Used to select an account when the cache contains + tokens for multiple identities. """ async def __aenter__(self): @@ -40,22 +46,19 @@ async def close(self): async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken": # pylint:disable=unused-argument """Get an access token for `scopes` from the shared cache. - .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. - If no access token is cached, attempt to acquire one using a cached refresh token. + .. note:: This method is called by Azure SDK clients. It isn't intended for use in application code. + :param str scopes: desired scopes for the token :rtype: :class:`azure.core.credentials.AccessToken` - :raises ~azure.core.exceptions.ClientAuthenticationError: when the cache is unavailable or no access token - can be acquired from it - - :keyword str authority: Authority of an Azure Active Directory endpoint, for example - 'login.microsoftonline.com', the authority for Azure Public Cloud (which is the default). - :class:`~azure.identity.KnownAuthorities` defines authorities for other clouds. + :raises ~azure.identity.CredentialUnavailableError: the cache is unavailable or contains insufficient user + information + :raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed """ if not self._client: - raise ClientAuthenticationError(message="Shared token cache unavailable") + raise CredentialUnavailableError(message="Shared token cache unavailable") account = self._get_account(self._username, self._tenant_id) @@ -64,8 +67,7 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken": # py token = await self._client.obtain_token_by_refresh_token(refresh_token, scopes) return token - raise ClientAuthenticationError(message=NO_TOKEN.format(account.get("username"))) + raise CredentialUnavailableError(message=NO_TOKEN.format(account.get("username"))) - @staticmethod - def _get_auth_client(**kwargs: "Any") -> "AadClientBase": + def _get_auth_client(self, **kwargs: "Any") -> "AadClientBase": return AadClient(tenant_id="common", client_id=AZURE_CLI_CLIENT_ID, **kwargs) diff --git a/sdk/identity/azure-identity/tests/test_environment_credential.py b/sdk/identity/azure-identity/tests/test_environment_credential.py new file mode 100644 index 000000000000..64f9b7b5fb78 --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_environment_credential.py @@ -0,0 +1,39 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import itertools +import os + +from azure.identity import CredentialUnavailableError, EnvironmentCredential +from azure.identity._constants import EnvironmentVariables +import pytest + +from helpers import mock + + +ALL_VARIABLES = { + _ + for _ in EnvironmentVariables.CLIENT_SECRET_VARS + + EnvironmentVariables.CERT_VARS + + EnvironmentVariables.USERNAME_PASSWORD_VARS +} + + +def test_error_message(): + """get_token should raise CredentialUnavailableError for incomplete configuration, listing any set variables.""" + + with mock.patch.dict(os.environ, {}): + with pytest.raises(CredentialUnavailableError) as ex: + EnvironmentCredential().get_token("scope") + assert not any(var in ex.value.message for var in ALL_VARIABLES) + + for a, b in itertools.combinations(ALL_VARIABLES, 2): # all credentials require at least 3 variables set + with mock.patch.dict(os.environ, {a: "a", b: "b"}): + with pytest.raises(CredentialUnavailableError) as ex: + EnvironmentCredential().get_token("scope") + + # error message should contain only the set variables + message = ex.value.message + assert a in message and b in message + assert not any(var in message for var in ALL_VARIABLES if var != a and var != b) diff --git a/sdk/identity/azure-identity/tests/test_environment_credential_async.py b/sdk/identity/azure-identity/tests/test_environment_credential_async.py new file mode 100644 index 000000000000..ce809291c2f3 --- /dev/null +++ b/sdk/identity/azure-identity/tests/test_environment_credential_async.py @@ -0,0 +1,32 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import itertools +import os + +from azure.identity import CredentialUnavailableError, EnvironmentCredential +import pytest + +from helpers import mock +from test_environment_credential import ALL_VARIABLES + + +@pytest.mark.asyncio +async def test_error_message(): + """get_token should raise CredentialUnavailableError for incomplete configuration, listing any set variables.""" + + with mock.patch.dict(os.environ, {}): + with pytest.raises(CredentialUnavailableError) as ex: + await EnvironmentCredential().get_token("scope") + assert not any(var in ex.value.message for var in ALL_VARIABLES) + + for a, b in itertools.combinations(ALL_VARIABLES, 2): # all credentials require at least 3 variables set + with mock.patch.dict(os.environ, {a: "a", b: "b"}): + with pytest.raises(CredentialUnavailableError) as ex: + await EnvironmentCredential().get_token("scope") + + # error message should contain only the set variables + message = ex.value.message + assert a in message and b in message + assert not any(var in message for var in ALL_VARIABLES if var != a and var != b) diff --git a/sdk/identity/azure-identity/tests/test_identity.py b/sdk/identity/azure-identity/tests/test_identity.py index 6898ff461b0c..60216c0e9d23 100644 --- a/sdk/identity/azure-identity/tests/test_identity.py +++ b/sdk/identity/azure-identity/tests/test_identity.py @@ -13,7 +13,13 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError -from azure.identity import ChainedTokenCredential, ClientSecretCredential, DefaultAzureCredential, EnvironmentCredential +from azure.identity import ( + ChainedTokenCredential, + ClientSecretCredential, + CredentialUnavailableError, + DefaultAzureCredential, + EnvironmentCredential, +) from azure.identity._credentials.managed_identity import ImdsCredential from azure.identity._constants import EnvironmentVariables import pytest @@ -54,13 +60,14 @@ def test_client_secret_environment_credential(): def test_credential_chain_error_message(): - def raise_authn_error(message): - raise ClientAuthenticationError(message) - first_error = "first_error" - first_credential = Mock(spec=ClientSecretCredential, get_token=lambda _: raise_authn_error(first_error)) + first_credential = Mock( + spec=ClientSecretCredential, get_token=Mock(side_effect=CredentialUnavailableError(first_error)) + ) second_error = "second_error" - second_credential = Mock(name="second_credential", get_token=lambda _: raise_authn_error(second_error)) + second_credential = Mock( + name="second_credential", get_token=Mock(side_effect=ClientAuthenticationError(second_error)) + ) with pytest.raises(ClientAuthenticationError) as ex: ChainedTokenCredential(first_credential, second_credential).get_token("scope") @@ -71,14 +78,11 @@ def raise_authn_error(message): def test_chain_attempts_all_credentials(): - def raise_authn_error(message="it didn't work"): - raise ClientAuthenticationError(message) - expected_token = AccessToken("expected_token", 0) credentials = [ - Mock(get_token=Mock(wraps=raise_authn_error)), - Mock(get_token=Mock(wraps=raise_authn_error)), + Mock(get_token=Mock(side_effect=CredentialUnavailableError(message=""))), + Mock(get_token=Mock(side_effect=CredentialUnavailableError(message=""))), Mock(get_token=Mock(return_value=expected_token)), ] @@ -89,6 +93,24 @@ def raise_authn_error(message="it didn't work"): assert credential.get_token.call_count == 1 +def test_chain_raises_for_unexpected_error(): + """the chain should not continue after an unexpected error (i.e. anything but CredentialUnavailableError)""" + + expected_message = "it can't be done" + + credentials = [ + Mock(get_token=Mock(side_effect=CredentialUnavailableError(message=""))), + Mock(get_token=Mock(side_effect=ValueError(expected_message))), + Mock(get_token=Mock(return_value=AccessToken("**", 42))), + ] + + with pytest.raises(ClientAuthenticationError) as ex: + ChainedTokenCredential(*credentials).get_token("scope") + + assert expected_message in ex.value.message + assert credentials[-1].get_token.call_count == 0 + + def test_chain_returns_first_token(): expected_token = Mock() first_credential = Mock(get_token=lambda _: expected_token) diff --git a/sdk/identity/azure-identity/tests/test_identity_async.py b/sdk/identity/azure-identity/tests/test_identity_async.py index 92cf75640323..8edbb0a2548a 100644 --- a/sdk/identity/azure-identity/tests/test_identity_async.py +++ b/sdk/identity/azure-identity/tests/test_identity_async.py @@ -11,6 +11,7 @@ import pytest from azure.core.credentials import AccessToken from azure.core.exceptions import ClientAuthenticationError +from azure.identity import CredentialUnavailableError from azure.identity.aio import ( ChainedTokenCredential, ClientSecretCredential, @@ -59,13 +60,14 @@ async def test_client_secret_environment_credential(): @pytest.mark.asyncio async def test_credential_chain_error_message(): - def raise_authn_error(message): - raise ClientAuthenticationError(message) - first_error = "first_error" - first_credential = Mock(spec=ClientSecretCredential, get_token=lambda _: raise_authn_error(first_error)) + first_credential = Mock( + spec=ClientSecretCredential, get_token=Mock(side_effect=CredentialUnavailableError(first_error)) + ) second_error = "second_error" - second_credential = Mock(name="second_credential", get_token=lambda _: raise_authn_error(second_error)) + second_credential = Mock( + name="second_credential", get_token=Mock(side_effect=ClientAuthenticationError(second_error)) + ) with pytest.raises(ClientAuthenticationError) as ex: await ChainedTokenCredential(first_credential, second_credential).get_token("scope") @@ -77,13 +79,13 @@ def raise_authn_error(message): @pytest.mark.asyncio async def test_chain_attempts_all_credentials(): - async def raise_authn_error(message="it didn't work"): - raise ClientAuthenticationError(message) + async def credential_unavailable(message="it didn't work"): + raise CredentialUnavailableError(message) expected_token = AccessToken("expected_token", 0) credentials = [ - Mock(get_token=Mock(wraps=raise_authn_error)), - Mock(get_token=Mock(wraps=raise_authn_error)), + Mock(get_token=Mock(wraps=credential_unavailable)), + Mock(get_token=Mock(wraps=credential_unavailable)), Mock(get_token=wrap_in_future(lambda _: expected_token)), ] @@ -94,6 +96,28 @@ async def raise_authn_error(message="it didn't work"): assert credential.get_token.call_count == 1 +@pytest.mark.asyncio +async def test_chain_raises_for_unexpected_error(): + """the chain should not continue after an unexpected error (i.e. anything but CredentialUnavailableError)""" + + async def credential_unavailable(message="it didn't work"): + raise CredentialUnavailableError(message) + + expected_message = "it can't be done" + + credentials = [ + Mock(get_token=Mock(wraps=credential_unavailable)), + Mock(get_token=Mock(side_effect=ValueError(expected_message))), + Mock(get_token=Mock(wraps=wrap_in_future(lambda _: AccessToken("**", 42)))) + ] + + with pytest.raises(ClientAuthenticationError) as ex: + await ChainedTokenCredential(*credentials).get_token("scope") + + assert expected_message in ex.value.message + assert credentials[-1].get_token.call_count == 0 + + @pytest.mark.asyncio async def test_chain_returns_first_token(): expected_token = Mock()