Azure / Azure/azure-sdk-for-python
DefaultAzureCredential.get_token_info() cache fast-path is missing the hasattr() guard that ChainedTokenCredential applies
- Dominant language
- Python
- Stars
- 5.6k
- Forks
- 3.4k
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 193
Description
- **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):
```python
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:
```python
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:
```python
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.
Contributor guide
Assessment
This issue has not been assessed yet.