Azure / Azure/azure-sdk-for-python

DefaultAzureCredential.get_token_info() cache fast-path is missing the hasattr() guard that ChainedTokenCredential applies

Open
#48,810 2 comments 1 reaction 1 assignee Claimed by @kashifkhan View on GitHub
Azure.Identity Client customer-reported needs-team-attention question
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.