Skip to content

Commit 992c402

Browse files
pvaneckCopilot
andauthored
[Identity] Improve WorkloadIdentityCredential/DAC diagnosability (#42346)
* [Identity] Improve WorkloadIdentityCredentia/DAC diagnosability The goal here is to have WorkloadIdentityCredential be reported in the the final error message as an attempted credential. A substitute credential for credentials that fail to construct has been added and used in the WorkloadIdentityCredential case. This will allow the reason WorkloadIdentityCredential can't be used to be propagated to the user. Signed-off-by: Paul Van Eck <[email protected]> * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> --------- Signed-off-by: Paul Van Eck <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 1beb378 commit 992c402

File tree

8 files changed

+256
-36
lines changed

8 files changed

+256
-36
lines changed

sdk/identity/azure-identity/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
### Other Changes
1414

1515
- `ManagedIdentityCredential` now retries IMDS 410 status responses for at least 70 seconds total duration as required by [Azure IMDS documentation](https://learn.microsoft.com/azure/virtual-machines/instance-metadata-service?tabs=windows#errors-and-debugging). ([#42330](https://github.com/Azure/azure-sdk-for-python/pull/42330))
16+
- Improved `DefaultAzureCredential` diagnostics when `WorkloadIdentityCredential` initialization fails. If DAC fails to find a successful credential in the chain, the reason `WorkloadIdentityCredential` failed will be included in the error message. ([#42346](https://github.com/Azure/azure-sdk-for-python/pull/42346))
1617

1718
## 1.24.0b1 (2025-07-17)
1819

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,16 @@
2323
def _get_error_message(history):
2424
attempts = []
2525
for credential, error in history:
26+
# Check if credential has a custom name (for DACErrorReporter instances)
27+
if hasattr(credential, "_credential_name"):
28+
credential_name = credential._credential_name # pylint: disable=protected-access
29+
else:
30+
credential_name = credential.__class__.__name__
31+
2632
if error:
27-
attempts.append("{}: {}".format(credential.__class__.__name__, error))
33+
attempts.append("{}: {}".format(credential_name, error))
2834
else:
29-
attempts.append(credential.__class__.__name__)
35+
attempts.append(credential_name)
3036
return """
3137
Attempted credentials:\n\t{}""".format(
3238
"\n\t".join(attempts)

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
import os
77
from typing import List, Any, Optional, cast
88

9-
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions, SupportsTokenInfo, TokenCredential
9+
from azure.core.credentials import (
10+
AccessToken,
11+
AccessTokenInfo,
12+
TokenRequestOptions,
13+
SupportsTokenInfo,
14+
TokenCredential,
15+
)
16+
from .. import CredentialUnavailableError
1017
from .._constants import EnvironmentVariables
1118
from .._internal.utils import get_default_authority, normalize_authority, within_dac, process_credential_exclusions
1219
from .azure_powershell import AzurePowerShellCredential
@@ -24,6 +31,32 @@
2431
_LOGGER = logging.getLogger(__name__)
2532

2633

34+
class FailedDACCredential:
35+
"""This acts as a substitute for a credential that has failed to initialize in the DAC chain.
36+
37+
This allows instantiation errors to be reported in ChainTokenCredential if all token requests fail.
38+
"""
39+
40+
def __init__(self, credential_name: str, error: str) -> None:
41+
self._error = error
42+
self._credential_name = credential_name
43+
44+
def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken:
45+
raise CredentialUnavailableError(self._error)
46+
47+
def get_token_info(self, *scopes, options: Optional[TokenRequestOptions] = None, **kwargs: Any) -> AccessTokenInfo:
48+
raise CredentialUnavailableError(self._error)
49+
50+
def __enter__(self) -> "FailedDACCredential":
51+
return self
52+
53+
def __exit__(self, *args: Any) -> None:
54+
pass
55+
56+
def close(self) -> None:
57+
pass
58+
59+
2760
class DefaultAzureCredential(ChainedTokenCredential):
2861
"""A credential capable of handling most Azure SDK authentication scenarios. For more information, See
2962
`Usage guidance for DefaultAzureCredential
@@ -216,16 +249,17 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement
216249
if not exclude_environment_credential:
217250
credentials.append(EnvironmentCredential(authority=authority, _within_dac=True, **kwargs))
218251
if not exclude_workload_identity_credential:
219-
if all(os.environ.get(var) for var in EnvironmentVariables.WORKLOAD_IDENTITY_VARS):
220-
client_id = workload_identity_client_id
252+
try:
221253
credentials.append(
222254
WorkloadIdentityCredential(
223-
client_id=cast(str, client_id),
255+
client_id=cast(str, workload_identity_client_id),
224256
tenant_id=workload_identity_tenant_id,
225-
token_file_path=os.environ[EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE],
257+
token_file_path=os.environ.get(EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE),
226258
**kwargs,
227259
)
228260
)
261+
except ValueError as ex:
262+
credentials.append(FailedDACCredential("WorkloadIdentityCredential", error=str(ex)))
229263
if not exclude_managed_identity_credential:
230264
credentials.append(
231265
ManagedIdentityCredential(

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

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
from .._constants import EnvironmentVariables
1212

1313

14+
WORKLOAD_CONFIG_ERROR = (
15+
"WorkloadIdentityCredential authentication unavailable. The workload options are not fully "
16+
"configured. See the troubleshooting guide for more information: "
17+
"https://aka.ms/azsdk/python/identity/workloadidentitycredential"
18+
)
19+
20+
1421
class TokenFileMixin:
1522

1623
_token_file_path: str
@@ -72,21 +79,25 @@ def __init__(
7279
tenant_id = tenant_id or os.environ.get(EnvironmentVariables.AZURE_TENANT_ID)
7380
client_id = client_id or os.environ.get(EnvironmentVariables.AZURE_CLIENT_ID)
7481
token_file_path = token_file_path or os.environ.get(EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE)
82+
83+
missing_args = []
7584
if not tenant_id:
76-
raise ValueError(
77-
"'tenant_id' is required. Please pass it in or set the "
78-
f"{EnvironmentVariables.AZURE_TENANT_ID} environment variable"
79-
)
85+
missing_args.append("'tenant_id'")
8086
if not client_id:
81-
raise ValueError(
82-
"'client_id' is required. Please pass it in or set the "
83-
f"{EnvironmentVariables.AZURE_CLIENT_ID} environment variable"
84-
)
87+
missing_args.append("'client_id'")
8588
if not token_file_path:
86-
raise ValueError(
87-
"'token_file_path' is required. Please pass it in or set the "
88-
f"{EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE} environment variable"
89-
)
89+
missing_args.append("'token_file_path'")
90+
91+
if missing_args:
92+
missing_args_str = ", ".join(missing_args)
93+
error_message = f"{WORKLOAD_CONFIG_ERROR}. Missing required arguments: {missing_args_str}."
94+
raise ValueError(error_message)
95+
96+
# Type assertions since we've validated these are not None
97+
assert tenant_id is not None
98+
assert client_id is not None
99+
assert token_file_path is not None
100+
90101
self._token_file_path = token_file_path
91102
super(WorkloadIdentityCredential, self).__init__(
92103
tenant_id=tenant_id,

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

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions
1010
from azure.core.credentials_async import AsyncTokenCredential, AsyncSupportsTokenInfo
11+
from ... import CredentialUnavailableError
1112
from ..._constants import EnvironmentVariables
1213
from ..._internal import get_default_authority, normalize_authority, within_dac, process_credential_exclusions
1314
from .azure_cli import AzureCliCredential
@@ -24,6 +25,34 @@
2425
_LOGGER = logging.getLogger(__name__)
2526

2627

28+
class AsyncFailedDACCredential:
29+
"""Async version of FailedDACCredential for use in async credential chains.
30+
31+
This acts as a substitute for an async credential that has failed to initialize in the DAC chain.
32+
"""
33+
34+
def __init__(self, credential_name: str, error: str) -> None:
35+
self._error = error
36+
self._credential_name = credential_name
37+
38+
async def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken:
39+
raise CredentialUnavailableError(self._error)
40+
41+
async def get_token_info(
42+
self, *scopes, options: Optional[TokenRequestOptions] = None, **kwargs: Any
43+
) -> AccessTokenInfo:
44+
raise CredentialUnavailableError(self._error)
45+
46+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
47+
pass
48+
49+
async def __aenter__(self) -> "AsyncFailedDACCredential":
50+
return self
51+
52+
async def close(self) -> None:
53+
pass
54+
55+
2756
class DefaultAzureCredential(ChainedTokenCredential):
2857
"""A credential capable of handling most Azure SDK authentication scenarios. See
2958
https://aka.ms/azsdk/python/identity/credential-chains#usage-guidance-for-defaultazurecredential.
@@ -180,16 +209,17 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement
180209
if not exclude_environment_credential:
181210
credentials.append(EnvironmentCredential(authority=authority, _within_dac=True, **kwargs))
182211
if not exclude_workload_identity_credential:
183-
if all(os.environ.get(var) for var in EnvironmentVariables.WORKLOAD_IDENTITY_VARS):
184-
client_id = workload_identity_client_id
212+
try:
185213
credentials.append(
186214
WorkloadIdentityCredential(
187-
client_id=cast(str, client_id),
215+
client_id=cast(str, workload_identity_client_id),
188216
tenant_id=workload_identity_tenant_id,
189-
token_file_path=os.environ[EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE],
217+
token_file_path=os.environ.get(EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE),
190218
**kwargs,
191219
)
192220
)
221+
except ValueError as ex:
222+
credentials.append(AsyncFailedDACCredential("WorkloadIdentityCredential", error=str(ex)))
193223
if not exclude_managed_identity_credential:
194224
credentials.append(
195225
ManagedIdentityCredential(

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

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
from typing import Any, Optional
77
from .client_assertion import ClientAssertionCredential
8-
from ..._credentials.workload_identity import TokenFileMixin
8+
from ..._credentials.workload_identity import TokenFileMixin, WORKLOAD_CONFIG_ERROR
99
from ..._constants import EnvironmentVariables
1010

1111

@@ -52,21 +52,25 @@ def __init__(
5252
tenant_id = tenant_id or os.environ.get(EnvironmentVariables.AZURE_TENANT_ID)
5353
client_id = client_id or os.environ.get(EnvironmentVariables.AZURE_CLIENT_ID)
5454
token_file_path = token_file_path or os.environ.get(EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE)
55+
56+
missing_args = []
5557
if not tenant_id:
56-
raise ValueError(
57-
"'tenant_id' is required. Please pass it in or set the "
58-
f"{EnvironmentVariables.AZURE_TENANT_ID} environment variable"
59-
)
58+
missing_args.append("'tenant_id'")
6059
if not client_id:
61-
raise ValueError(
62-
"'client_id' is required. Please pass it in or set the "
63-
f"{EnvironmentVariables.AZURE_CLIENT_ID} environment variable"
64-
)
60+
missing_args.append("'client_id'")
6561
if not token_file_path:
66-
raise ValueError(
67-
"'token_file_path' is required. Please pass it in or set the "
68-
f"{EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE} environment variable"
69-
)
62+
missing_args.append("'token_file_path'")
63+
64+
if missing_args:
65+
missing_args_str = ", ".join(missing_args)
66+
error_message = f"{WORKLOAD_CONFIG_ERROR}. Missing required arguments: {missing_args_str}."
67+
raise ValueError(error_message)
68+
69+
# Type assertions since we've validated these are not None
70+
assert tenant_id is not None
71+
assert client_id is not None
72+
assert token_file_path is not None
73+
7074
self._token_file_path = token_file_path
7175
super().__init__(
7276
tenant_id=tenant_id,

sdk/identity/azure-identity/tests/test_default.py

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

88
from azure.core.credentials import AccessToken, AccessTokenInfo
9+
from azure.core.exceptions import ClientAuthenticationError
910
from azure.identity import (
1011
AzureCliCredential,
1112
AzureDeveloperCliCredential,
@@ -504,3 +505,68 @@ def test_broker_credential_requirements_not_installed():
504505
broker_cred = broker_credentials[0]
505506
with pytest.raises(CredentialUnavailableError) as exc_info:
506507
broker_cred.get_token_info("https://management.azure.com/.default")
508+
509+
510+
def test_failed_dac_credential_error_reporting():
511+
"""Test that FailedDACCredential properly reports initialization errors"""
512+
from azure.identity._credentials.default import FailedDACCredential
513+
514+
credential_name = "WorkloadIdentityCredential"
515+
error_message = "Failed to initialize: missing required environment variable AZURE_FEDERATED_TOKEN_FILE"
516+
517+
failed_credential = FailedDACCredential(credential_name, error_message)
518+
519+
# Test get_token raises CredentialUnavailableError with the original error
520+
with pytest.raises(CredentialUnavailableError) as exc_info:
521+
failed_credential.get_token("https://management.azure.com/.default")
522+
523+
assert str(exc_info.value) == error_message
524+
525+
# Test get_token_info raises CredentialUnavailableError with the original error
526+
with pytest.raises(CredentialUnavailableError) as exc_info:
527+
failed_credential.get_token_info("https://management.azure.com/.default")
528+
529+
assert str(exc_info.value) == error_message
530+
531+
# Test context manager support
532+
with failed_credential:
533+
pass # Should not raise during context entry/exit
534+
535+
# Test close method
536+
failed_credential.close() # Should not raise
537+
538+
539+
def test_failed_dac_credential_in_chain():
540+
"""Test that FailedDACCredential errors are properly reported when DefaultAzureCredential fails"""
541+
from azure.identity._credentials.default import FailedDACCredential
542+
543+
# Create a mock successful credential to ensure the chain doesn't fail immediately
544+
successful_credential = Mock(
545+
spec_set=["get_token", "get_token_info"],
546+
get_token=Mock(return_value=AccessToken("***", 42)),
547+
get_token_info=Mock(return_value=AccessTokenInfo("***", 42)),
548+
)
549+
550+
# Create a DefaultAzureCredential and replace its credentials with a failed credential and successful one
551+
credential = DefaultAzureCredential()
552+
failed_cred = FailedDACCredential("WorkloadIdentityCredential", "initialization error")
553+
credential.credentials = (failed_cred, successful_credential)
554+
555+
# The chain should succeed using the successful credential
556+
token = credential.get_token("https://management.azure.com/.default")
557+
assert token.token == "***"
558+
assert token.expires_on == 42
559+
560+
# Test with only failed credentials to ensure error propagation
561+
credential_all_failed = DefaultAzureCredential()
562+
failed_cred1 = FailedDACCredential("WorkloadIdentityCredential", "workload identity error")
563+
failed_cred2 = FailedDACCredential("TestCredential", "test credential error")
564+
credential_all_failed.credentials = (failed_cred1, failed_cred2)
565+
566+
# Should raise an error that includes both credential errors
567+
with pytest.raises(ClientAuthenticationError) as exc_info:
568+
credential_all_failed.get_token("https://management.azure.com/.default")
569+
570+
# The error should mention the failed credentials
571+
error_str = str(exc_info.value)
572+
assert "workload identity error" in error_str or "test credential error" in error_str

0 commit comments

Comments
 (0)