- Package Name: azure-identity
- Package Version: 1.25.3 (also reproduced on 1.18.0 through 1.25.2, and current main)
- Operating System: Ubuntu 24.04 LTS
- Python Version: 3.12.3
Describe the bug
DefaultAzureCredential.get_token_info() has a cache fast-path that calls
get_token_info() on the previously successful credential without checking that the
credential actually implements it (azure/identity/_credentials/default.py, line 368 in 1.25.3):
if self._successful_credential:
token_info = cast(SupportsTokenInfo, self._successful_credential).get_token_info(*scopes, options=options)
ChainedTokenCredential.get_token_info() guards the same situation with hasattr() and
falls back to get_token() . DefaultAzureCredential overrides the method and drops that guard.
The consequence is order-dependent and easy to miss: when the chain resolves to a credential
that only implements the legacy get_token() protocol, the first call succeeds through the
guarded cold path and caches the credential, and every subsequent call raises AttributeError .
This is not hypothetical. On Azure Databricks, setting DATABRICKS_DEFAULT_SERVICE_CREDENTIAL_NAME
makes the runtime inject a ServiceCredentialTokenProvider into the chain, and that provider
implements only get_token() . Since azure-core's BearerTokenCredentialPolicy prefers
get_token_info() whenever the credential exposes it (azure-core >= 1.31.0), this breaks every
SDK client built on DefaultAzureCredential after the first token request.
To Reproduce
Self-contained, no Azure or Databricks account needed. LegacyOnlyCredential stands in for any
credential implementing only the legacy protocol:
from azure.core.credentials import AccessToken
from azure.identity import DefaultAzureCredential
class LegacyOnlyCredential:
"""Implements only the legacy get_token() protocol,
like dbruntime.servicecredentials.ServiceCredentialTokenProvider."""
def get_token(self, *scopes, **kwargs):
return AccessToken("fake-token", 9999999999)
cred = DefaultAzureCredential()
cred.credentials = (LegacyOnlyCredential(),)
for i in (1, 2):
try:
info = cred.get_token_info("https://vault.azure.net/.default")
print(f"call {i}: OK -> {info.token}")
except AttributeError as e:
print(f"call {i}: AttributeError -> {e}")
Output on 1.25.3:
call 1: OK -> fake-token
call 2: AttributeError -> 'LegacyOnlyCredential' object has no attribute 'get_token_info'
To Reproduce
Self-contained, no Azure or Databricks account needed. LegacyOnlyCredential stands in for any
credential implementing only the legacy protocol:
from azure.core.credentials import AccessToken
from azure.identity import DefaultAzureCredential
class LegacyOnlyCredential:
"""Implements only the legacy get_token() protocol,
like dbruntime.servicecredentials.ServiceCredentialTokenProvider."""
def get_token(self, *scopes, **kwargs):
return AccessToken("fake-token", 9999999999)
cred = DefaultAzureCredential()
cred.credentials = (LegacyOnlyCredential(),)
for i in (1, 2):
try:
info = cred.get_token_info("https://vault.azure.net/.default")
print(f"call {i}: OK -> {info.token}")
except AttributeError as e:
print(f"call {i}: AttributeError -> {e}")
Expected behavior
Both calls succeed. The fast-path should apply the same hasattr() fallback that
ChainedTokenCredential.get_token_info() already implements, i.e. fall back to get_token()
and wrap the result in AccessTokenInfo when the cached credential does not support
get_token_info() .
Screenshots
N/A
Additional context
Versions: get_token_info() was added to DefaultAzureCredential in 1.18.0, and the unguarded
fast-path has been present since. 1.17.1 is unaffected because it does not implement
get_token_info() at all, so azure-core's own hasattr() guard in
policies/_authentication.py falls back to get_token() .
This is not merely a consequence of injecting a custom credential. _successful_credential
is populated by two paths in ChainedTokenCredential: get_token_info() sets it after
checking hasattr(credential, "get_token_info"), but get_token() sets it (chained.py, line 131)
after checking only hasattr(credential, "get_token") (line 116). A credential cached through the
get_token() path is therefore guaranteed to implement only get_token, yet
DefaultAzureCredential.get_token_info() reads that same cache and calls get_token_info() on it
unconditionally. The class writes a weaker guarantee than the one it later relies on.
Workaround: pin azure-identity<1.18 . Note that overriding get_token_info() in a
DefaultAzureCredential subclass is not a safe workaround on 1.17.1, because defining the
method makes azure-core's hasattr() check succeed again.
Describe the bug
DefaultAzureCredential.get_token_info()has a cache fast-path that callsget_token_info()on the previously successful credential without checking that thecredential actually implements it (
azure/identity/_credentials/default.py, line 368 in 1.25.3):ChainedTokenCredential.get_token_info() guards the same situation with hasattr() and
falls back to get_token() . DefaultAzureCredential overrides the method and drops that guard.
The consequence is order-dependent and easy to miss: when the chain resolves to a credential
that only implements the legacy get_token() protocol, the first call succeeds through the
guarded cold path and caches the credential, and every subsequent call raises AttributeError .
This is not hypothetical. On Azure Databricks, setting DATABRICKS_DEFAULT_SERVICE_CREDENTIAL_NAME
makes the runtime inject a ServiceCredentialTokenProvider into the chain, and that provider
implements only get_token() . Since azure-core's BearerTokenCredentialPolicy prefers
get_token_info() whenever the credential exposes it (azure-core >= 1.31.0), this breaks every
SDK client built on DefaultAzureCredential after the first token request.
To Reproduce
Self-contained, no Azure or Databricks account needed. LegacyOnlyCredential stands in for any
credential implementing only the legacy protocol:
Output on 1.25.3:
call 1: OK -> fake-token
call 2: AttributeError -> 'LegacyOnlyCredential' object has no attribute 'get_token_info'
To Reproduce
Self-contained, no Azure or Databricks account needed. LegacyOnlyCredential stands in for any
credential implementing only the legacy protocol:
Expected behavior
Both calls succeed. The fast-path should apply the same hasattr() fallback that
ChainedTokenCredential.get_token_info() already implements, i.e. fall back to get_token()
and wrap the result in AccessTokenInfo when the cached credential does not support
get_token_info() .
Screenshots
N/A
Additional context
Versions: get_token_info() was added to DefaultAzureCredential in 1.18.0, and the unguarded
fast-path has been present since. 1.17.1 is unaffected because it does not implement
get_token_info() at all, so azure-core's own hasattr() guard in
policies/_authentication.py falls back to get_token() .
This is not merely a consequence of injecting a custom credential.
_successful_credentialis populated by two paths in
ChainedTokenCredential:get_token_info()sets it afterchecking
hasattr(credential, "get_token_info"), butget_token()sets it (chained.py, line 131)after checking only
hasattr(credential, "get_token")(line 116). A credential cached through theget_token()path is therefore guaranteed to implement onlyget_token, yetDefaultAzureCredential.get_token_info()reads that same cache and callsget_token_info()on itunconditionally. The class writes a weaker guarantee than the one it later relies on.
Workaround: pin azure-identity<1.18 . Note that overriding get_token_info() in a
DefaultAzureCredential subclass is not a safe workaround on 1.17.1, because defining the
method makes azure-core's hasattr() check succeed again.