Skip to content

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

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):

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Azure.IdentityClientThis issue points to a problem in the data-plane of the library.customer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK teamquestionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions