Skip to content

Commit f5d602a

Browse files
authored
Distinguish credential unavailability from failure (#9372)
1 parent af8e0f1 commit f5d602a

17 files changed

Lines changed: 262 additions & 77 deletions

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
- Correctly parse token expiration time on Windows App Service
66
([#9393](https://github.com/Azure/azure-sdk-for-python/issues/9393))
7+
- Credentials raise `CredentialUnavailableError` when they can't attempt to
8+
authenticate due to missing data or state
9+
([#9372](https://github.com/Azure/azure-sdk-for-python/pull/9372))
10+
711

812
## 1.2.0 (2020-01-14)
913

sdk/identity/azure-identity/README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,14 @@ client = SecretClient("https://my-vault.vault.azure.net", credential)
189189

190190
## Chaining credentials:
191191
[ChainedTokenCredential][chain_cred_ref] links multiple credential instances
192-
to be tried sequentially when authenticating. The following example demonstrates
193-
creating a credential which will attempt to authenticate using managed identity,
194-
and fall back to a service principal if a managed identity is unavailable. This
195-
example uses the `EventHubClient` from the [azure-eventhub][azure_eventhub]
196-
client library.
192+
to be tried sequentially when authenticating. It will try each chained
193+
credential in turn until one provides a token or fails to authenticate due to
194+
an error.
195+
196+
The following example demonstrates creating a credential which will attempt to
197+
authenticate using managed identity, and fall back to a service principal when
198+
managed identity is unavailable. This example uses the `EventHubClient` from
199+
the [azure-eventhub][azure_eventhub] client library.
197200

198201
```py
199202
from azure.eventhub import EventHubClient
@@ -202,8 +205,8 @@ from azure.identity import ChainedTokenCredential, ClientSecretCredential, Manag
202205
managed_identity = ManagedIdentityCredential()
203206
service_principal = ClientSecretCredential(tenant_id, client_id, client_secret)
204207

205-
# when an access token is needed, the chain will try each
206-
# credential in order, stopping when one provides a token
208+
# when an access token is needed, the chain will try each credential in order,
209+
# stopping when one provides a token or fails to authenticate due to an error
207210
credential_chain = ChainedTokenCredential(managed_identity, service_principal)
208211

209212
# the ChainedTokenCredential can be used anywhere a credential is required
@@ -252,6 +255,11 @@ client = SecretClient("https://my-vault.vault.azure.net", default_credential)
252255

253256
# Troubleshooting
254257
## General
258+
Credentials raise `CredentialUnavailableError` when they're unable to attempt
259+
authentication because they lack required data or state. For example,
260+
[EnvironmentCredential][environment_cred_ref] will raise this exception when
261+
[its configuration](#environment-variables "its configuration") is incomplete.
262+
255263
Credentials raise `azure.core.exceptions.ClientAuthenticationError` when they fail
256264
to authenticate. `ClientAuthenticationError` has a `message` attribute which
257265
describes why authentication failed. When raised by

sdk/identity/azure-identity/azure/identity/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# ------------------------------------
55
"""Credentials for Azure SDK clients."""
66

7+
from ._exceptions import CredentialUnavailableError
78
from ._constants import KnownAuthorities
89
from ._credentials import (
910
AuthorizationCodeCredential,
@@ -25,6 +26,7 @@
2526
"CertificateCredential",
2627
"ChainedTokenCredential",
2728
"ClientSecretCredential",
29+
"CredentialUnavailableError",
2830
"DefaultAzureCredential",
2931
"DeviceCodeCredential",
3032
"EnvironmentCredential",
@@ -36,4 +38,5 @@
3638
]
3739

3840
from ._version import VERSION
41+
3942
__version__ = VERSION

sdk/identity/azure-identity/azure/identity/_credentials/browser.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from azure.core.credentials import AccessToken
1111
from azure.core.exceptions import ClientAuthenticationError
1212

13+
from .. import CredentialUnavailableError
1314
from .._constants import AZURE_CLI_CLIENT_ID
1415
from .._internal import AuthCodeRedirectServer, PublicClientCredential, wrap_exceptions
1516

@@ -38,7 +39,6 @@ class InteractiveBrowserCredential(PublicClientCredential):
3839
:keyword str client_id: Client ID of the Azure Active Directory application users will sign in to. If
3940
unspecified, the Azure CLI's ID will be used.
4041
:keyword int timeout: seconds to wait for the user to complete authentication. Defaults to 300 (5 minutes).
41-
4242
"""
4343

4444
def __init__(self, **kwargs):
@@ -60,7 +60,9 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
6060
6161
:param str scopes: desired scopes for the token
6262
:rtype: :class:`azure.core.credentials.AccessToken`
63-
:raises ~azure.core.exceptions.ClientAuthenticationError:
63+
:raises ~azure.identity.CredentialUnavailableError: the credential is unable to start an HTTP server on
64+
localhost, or is unable to open a browser
65+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed
6466
"""
6567
return self._get_token_from_cache(scopes, **kwargs) or self._get_token_by_auth_code(scopes, **kwargs)
6668

@@ -88,7 +90,7 @@ def _get_token_by_auth_code(self, scopes, **kwargs):
8890
continue # keep looking for an open port
8991

9092
if not redirect_uri:
91-
raise ClientAuthenticationError(message="Couldn't start an HTTP server on localhost")
93+
raise CredentialUnavailableError(message="Couldn't start an HTTP server on localhost")
9294

9395
# get the url the user must visit to authenticate
9496
scopes = list(scopes) # type: ignore
@@ -100,7 +102,7 @@ def _get_token_by_auth_code(self, scopes, **kwargs):
100102

101103
# open browser to that url
102104
if not webbrowser.open(auth_url):
103-
raise ClientAuthenticationError(message="Failed to open a browser")
105+
raise CredentialUnavailableError(message="Failed to open a browser")
104106

105107
# block until the server times out or receives the post-authentication redirect
106108
response = server.wait_for_redirect()

sdk/identity/azure-identity/azure/identity/_credentials/chained.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# ------------------------------------
55
from azure.core.exceptions import ClientAuthenticationError
66

7+
from .. import CredentialUnavailableError
8+
79
try:
810
from typing import TYPE_CHECKING
911
except ImportError:
@@ -51,15 +53,19 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
5153
.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.
5254
5355
:param str scopes: desired scopes for the token
54-
:raises ~azure.core.exceptions.ClientAuthenticationError: when no credential in the chain provides a token
56+
:raises ~azure.core.exceptions.ClientAuthenticationError: no credential in the chain provided a token
5557
"""
5658
history = []
5759
for credential in self.credentials:
5860
try:
5961
return credential.get_token(*scopes, **kwargs)
60-
except ClientAuthenticationError as ex:
62+
except CredentialUnavailableError as ex:
63+
# credential didn't attempt authentication because it lacks required data or state -> continue
6164
history.append((credential, ex.message))
6265
except Exception as ex: # pylint: disable=broad-except
66+
# credential failed to authenticate, or something unexpectedly raised -> break
6367
history.append((credential, str(ex)))
68+
break
69+
6470
error_message = _get_error_message(history)
6571
raise ClientAuthenticationError(message=error_message)

sdk/identity/azure-identity/azure/identity/_credentials/environment.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# ------------------------------------
55
import os
66

7-
from azure.core.exceptions import ClientAuthenticationError
7+
from .. import CredentialUnavailableError
88
from .._constants import EnvironmentVariables
99
from .client_credential import CertificateCredential, ClientSecretCredential
1010
from .user import UsernamePasswordCredential
@@ -22,7 +22,27 @@
2222
EnvironmentCredentialTypes = Union["CertificateCredential", "ClientSecretCredential", "UsernamePasswordCredential"]
2323

2424

25-
class EnvironmentCredential:
25+
def get_credential_unavailable_message():
26+
# type: () -> str
27+
message = (
28+
"Incomplete environment configuration. See "
29+
+ "https://aka.ms/python-sdk-identity#environment-variables for expected environment variables"
30+
)
31+
32+
all_variables = {
33+
_
34+
for _ in EnvironmentVariables.CLIENT_SECRET_VARS
35+
+ EnvironmentVariables.CERT_VARS
36+
+ EnvironmentVariables.USERNAME_PASSWORD_VARS
37+
}
38+
set_variables = ", ".join(v for v in all_variables if v in os.environ)
39+
if set_variables:
40+
message += ". Currently set variables: {}".format(set_variables)
41+
42+
return message
43+
44+
45+
class EnvironmentCredential(object):
2646
"""A credential configured by environment variables.
2747
2848
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:
5171
def __init__(self, **kwargs):
5272
# type: (Mapping[str, Any]) -> None
5373
self._credential = None # type: Optional[EnvironmentCredentialTypes]
74+
self._unavailable_message = ""
5475

5576
if all(os.environ.get(v) is not None for v in EnvironmentVariables.CLIENT_SECRET_VARS):
5677
self._credential = ClientSecretCredential(
@@ -75,6 +96,9 @@ def __init__(self, **kwargs):
7596
**kwargs
7697
)
7798

99+
if not self._credential:
100+
self._unavailable_message = get_credential_unavailable_message()
101+
78102
def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
79103
# type: (*str, **Any) -> AccessToken
80104
"""Request an access token for `scopes`.
@@ -83,8 +107,8 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
83107
84108
:param str scopes: desired scopes for the token
85109
:rtype: :class:`azure.core.credentials.AccessToken`
86-
:raises ~azure.core.exceptions.ClientAuthenticationError:
110+
:raises ~azure.identity.CredentialUnavailableError: environment variable configuration is incomplete
87111
"""
88112
if not self._credential:
89-
raise ClientAuthenticationError(message="Incomplete environment configuration")
113+
raise CredentialUnavailableError(message=self._unavailable_message)
90114
return self._credential.get_token(*scopes, **kwargs)

sdk/identity/azure-identity/azure/identity/_credentials/managed_identity.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from azure.core.configuration import Configuration
88
from azure.core.credentials import AccessToken
9-
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError
9+
from azure.core.exceptions import HttpResponseError
1010
from azure.core.pipeline.policies import (
1111
ContentDecodePolicy,
1212
DistributedTracingPolicy,
@@ -17,6 +17,7 @@
1717
UserAgentPolicy,
1818
)
1919

20+
from .. import CredentialUnavailableError
2021
from .._authn_client import AuthnClient
2122
from .._constants import Endpoints, EnvironmentVariables
2223
from .._internal.user_agent import USER_AGENT
@@ -59,7 +60,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument,no-sel
5960
6061
:param str scopes: desired scopes for the token
6162
:rtype: :class:`azure.core.credentials.AccessToken`
62-
:raises ~azure.core.exceptions.ClientAuthenticationError:
63+
:raises ~azure.identity.CredentialUnavailableError: managed identity isn't available in the hosting environment
6364
"""
6465
return AccessToken()
6566

@@ -132,7 +133,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
132133
133134
:param str scopes: desired scopes for the token
134135
:rtype: :class:`azure.core.credentials.AccessToken`
135-
:raises ~azure.core.exceptions.ClientAuthenticationError:
136+
:raises ~azure.identity.CredentialUnavailableError: the IMDS endpoint is unreachable
136137
"""
137138
if self._endpoint_available is None:
138139
# 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
149150
self._endpoint_available = False
150151

151152
if not self._endpoint_available:
152-
raise ClientAuthenticationError(message="IMDS endpoint unavailable")
153+
raise CredentialUnavailableError(message="IMDS endpoint unavailable")
153154

154155
if len(scopes) != 1:
155156
raise ValueError("this credential supports one scope per request")
@@ -184,11 +185,11 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
184185
185186
:param str scopes: desired scopes for the token
186187
:rtype: :class:`azure.core.credentials.AccessToken`
187-
:raises ~azure.core.exceptions.ClientAuthenticationError:
188+
:raises ~azure.identity.CredentialUnavailableError: the MSI endpoint is unavailable
188189
"""
189190

190191
if not self._endpoint:
191-
raise ClientAuthenticationError(message="MSI endpoint unavailable")
192+
raise CredentialUnavailableError(message="MSI endpoint unavailable")
192193

193194
if len(scopes) != 1:
194195
raise ValueError("this credential supports only one scope per request")

sdk/identity/azure-identity/azure/identity/_credentials/shared_cache.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
# Copyright (c) Microsoft Corporation.
33
# Licensed under the MIT License.
44
# ------------------------------------
5-
6-
from azure.core.exceptions import ClientAuthenticationError
5+
from .. import CredentialUnavailableError
76
from .._constants import AZURE_CLI_CLIENT_ID
87
from .._internal import AadClient, wrap_exceptions
98
from .._internal.shared_token_cache import NO_TOKEN, SharedTokenCacheBase
@@ -45,13 +44,13 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
4544
4645
:param str scopes: desired scopes for the token
4746
:rtype: :class:`azure.core.credentials.AccessToken`
48-
:raises:
49-
:class:`azure.core.exceptions.ClientAuthenticationError` when the cache is unavailable or no access token
50-
can be acquired from it
47+
:raises ~azure.identity.CredentialUnavailableError: the cache is unavailable or contains insufficient user
48+
information
49+
:raises ~azure.core.exceptions.ClientAuthenticationError: authentication failed
5150
"""
5251

5352
if not self._client:
54-
raise ClientAuthenticationError(message="Shared token cache unavailable")
53+
raise CredentialUnavailableError(message="Shared token cache unavailable")
5554

5655
account = self._get_account(self._username, self._tenant_id)
5756

@@ -60,7 +59,7 @@ def get_token(self, *scopes, **kwargs): # pylint:disable=unused-argument
6059
token = self._client.obtain_token_by_refresh_token(refresh_token, scopes)
6160
return token
6261

63-
raise ClientAuthenticationError(message=NO_TOKEN.format(account.get("username")))
62+
raise CredentialUnavailableError(message=NO_TOKEN.format(account.get("username")))
6463

6564
def _get_auth_client(self, **kwargs):
6665
# type: (**Any) -> AadClientBase
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# ------------------------------------
2+
# Copyright (c) Microsoft Corporation.
3+
# Licensed under the MIT License.
4+
# ------------------------------------
5+
from azure.core.exceptions import ClientAuthenticationError
6+
7+
8+
class CredentialUnavailableError(ClientAuthenticationError):
9+
"""The credential did not attempt to authenticate because required data or state is unavailable."""

sdk/identity/azure-identity/azure/identity/aio/_credentials/chained.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from azure.core.exceptions import ClientAuthenticationError
99
from .base import AsyncCredentialBase
10+
from ... import CredentialUnavailableError
1011
from ..._credentials.chained import _get_error_message
1112

1213
if TYPE_CHECKING:
@@ -44,15 +45,19 @@ async def get_token(self, *scopes: str, **kwargs: "Any") -> "AccessToken":
4445
.. note:: This method is called by Azure SDK clients. It isn't intended for use in application code.
4546
4647
:param str scopes: desired scopes for the token
47-
:raises ~azure.core.exceptions.ClientAuthenticationError:
48+
:raises ~azure.core.exceptions.ClientAuthenticationError: no credential in the chain provided a token
4849
"""
4950
history = []
5051
for credential in self.credentials:
5152
try:
5253
return await credential.get_token(*scopes, **kwargs)
53-
except ClientAuthenticationError as ex:
54+
except CredentialUnavailableError as ex:
55+
# credential didn't attempt authentication because it lacks required data or state -> continue
5456
history.append((credential, ex.message))
5557
except Exception as ex: # pylint: disable=broad-except
58+
# credential failed to authenticate, or something unexpectedly raised -> break
5659
history.append((credential, str(ex)))
60+
break
61+
5762
error_message = _get_error_message(history)
5863
raise ClientAuthenticationError(message=error_message)

0 commit comments

Comments
 (0)