Skip to content
Merged
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
4 changes: 4 additions & 0 deletions sdk/identity/azure-identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
22 changes: 15 additions & 7 deletions sdk/identity/azure-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions sdk/identity/azure-identity/azure/identity/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# ------------------------------------
"""Credentials for Azure SDK clients."""

from ._exceptions import CredentialUnavailableError
from ._constants import KnownAuthorities
from ._credentials import (
AuthorizationCodeCredential,
Expand All @@ -25,6 +26,7 @@
"CertificateCredential",
"ChainedTokenCredential",
"ClientSecretCredential",
"CredentialUnavailableError",
"DefaultAzureCredential",
"DeviceCodeCredential",
"EnvironmentCredential",
Expand All @@ -36,4 +38,5 @@
]

from ._version import VERSION

__version__ = VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# ------------------------------------
from azure.core.exceptions import ClientAuthenticationError

from .. import CredentialUnavailableError

try:
from typing import TYPE_CHECKING
except ImportError:
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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`.
Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions sdk/identity/azure-identity/azure/identity/_exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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__()
Expand All @@ -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)
Loading