diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index ae38675266c8..bb69428b4167 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -1,9 +1,12 @@ # Release History -## 1.23.1 (Unreleased) +## 1.24.0b1 (Unreleased) ### Features Added +- Expanded the set of acceptable values for environment variable `AZURE_TOKEN_CREDENTIALS` to allow for selection of a specific credential in the `DefaultAzureCredential` chain. At runtime, only the specified credential will be used when acquiring tokens with `DefaultAzureCredential`. For example, setting `AZURE_TOKEN_CREDENTIALS=WorkloadIdentityCredential` will make `DefaultAzureCredential` use only `WorkloadIdentityCredential`. + - Valid values are `EnvironmentCredential`, `WorkloadIdentityCredential`, `ManagedIdentityCredential`, `AzureCliCredential`, `AzurePowershellCredential`, `AzureDeveloperCliCredential`, and `InteractiveBrowserCredential`. ([#41709](https://github.com/Azure/azure-sdk-for-python/pull/41709)) + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/default.py b/sdk/identity/azure-identity/azure/identity/_credentials/default.py index 8c2e6c1bcced..798afb842846 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/default.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/default.py @@ -8,7 +8,7 @@ from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions, SupportsTokenInfo, TokenCredential from .._constants import EnvironmentVariables -from .._internal import get_default_authority, normalize_authority, within_dac +from .._internal import get_default_authority, normalize_authority, within_dac, process_credential_exclusions from .azure_powershell import AzurePowerShellCredential from .browser import InteractiveBrowserCredential from .chained import ChainedTokenCredential @@ -133,36 +133,74 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement process_timeout = kwargs.pop("process_timeout", 10) - token_credentials_env = os.environ.get(EnvironmentVariables.AZURE_TOKEN_CREDENTIALS, "").strip().lower() - exclude_workload_identity_credential = kwargs.pop("exclude_workload_identity_credential", False) - exclude_environment_credential = kwargs.pop("exclude_environment_credential", False) - exclude_managed_identity_credential = kwargs.pop("exclude_managed_identity_credential", False) - exclude_shared_token_cache_credential = kwargs.pop("exclude_shared_token_cache_credential", False) - exclude_visual_studio_code_credential = kwargs.pop("exclude_visual_studio_code_credential", True) - exclude_developer_cli_credential = kwargs.pop("exclude_developer_cli_credential", False) - exclude_cli_credential = kwargs.pop("exclude_cli_credential", False) - exclude_interactive_browser_credential = kwargs.pop("exclude_interactive_browser_credential", True) - exclude_powershell_credential = kwargs.pop("exclude_powershell_credential", False) - - if token_credentials_env == "dev": - # In dev mode, use only developer credentials - exclude_environment_credential = True - exclude_managed_identity_credential = True - exclude_workload_identity_credential = True - elif token_credentials_env == "prod": - # In prod mode, use only production credentials - exclude_shared_token_cache_credential = True - exclude_visual_studio_code_credential = True - exclude_cli_credential = True - exclude_developer_cli_credential = True - exclude_powershell_credential = True - exclude_interactive_browser_credential = True - elif token_credentials_env != "": - # If the environment variable is set to something other than dev or prod, raise an error - raise ValueError( - f"Invalid value for {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS}: {token_credentials_env}. " - "Valid values are 'dev' or 'prod'." - ) + # Define credential configuration mapping + credential_config = { + "environment": { + "exclude_param": "exclude_environment_credential", + "env_name": "environmentcredential", + "default_exclude": False, + }, + "workload_identity": { + "exclude_param": "exclude_workload_identity_credential", + "env_name": "workloadidentitycredential", + "default_exclude": False, + }, + "managed_identity": { + "exclude_param": "exclude_managed_identity_credential", + "env_name": "managedidentitycredential", + "default_exclude": False, + }, + "shared_token_cache": { + "exclude_param": "exclude_shared_token_cache_credential", + "default_exclude": False, + }, + "visual_studio_code": { + "exclude_param": "exclude_visual_studio_code_credential", + "default_exclude": True, + }, + "cli": { + "exclude_param": "exclude_cli_credential", + "env_name": "azureclicredential", + "default_exclude": False, + }, + "developer_cli": { + "exclude_param": "exclude_developer_cli_credential", + "env_name": "azuredeveloperclicredential", + "default_exclude": False, + }, + "powershell": { + "exclude_param": "exclude_powershell_credential", + "env_name": "azurepowershellcredential", + "default_exclude": False, + }, + "interactive_browser": { + "exclude_param": "exclude_interactive_browser_credential", + "env_name": "interactivebrowsercredential", + "default_exclude": True, + }, + } + + # Extract user-provided exclude flags and set defaults + exclude_flags = {} + user_excludes = {} + for cred_key, config in credential_config.items(): + param_name = cast(str, config["exclude_param"]) + user_excludes[cred_key] = kwargs.pop(param_name, None) + exclude_flags[cred_key] = config["default_exclude"] + + # Process AZURE_TOKEN_CREDENTIALS environment variable and apply user overrides + exclude_flags = process_credential_exclusions(credential_config, exclude_flags, user_excludes) + + # Extract individual exclude flags for backward compatibility + exclude_environment_credential = exclude_flags["environment"] + exclude_workload_identity_credential = exclude_flags["workload_identity"] + exclude_managed_identity_credential = exclude_flags["managed_identity"] + exclude_shared_token_cache_credential = exclude_flags["shared_token_cache"] + exclude_visual_studio_code_credential = exclude_flags["visual_studio_code"] + exclude_cli_credential = exclude_flags["cli"] + exclude_developer_cli_credential = exclude_flags["developer_cli"] + exclude_powershell_credential = exclude_flags["powershell"] + exclude_interactive_browser_credential = exclude_flags["interactive_browser"] credentials: List[SupportsTokenInfo] = [] within_dac.set(True) diff --git a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py index 6bc3387d0098..c0efa58d084a 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/__init__.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/__init__.py @@ -11,6 +11,7 @@ from .utils import ( get_default_authority, normalize_authority, + process_credential_exclusions, resolve_tenant, validate_scope, validate_subscription, @@ -48,6 +49,7 @@ def _scopes_to_resource(*scopes) -> str: "get_default_authority", "InteractiveCredential", "normalize_authority", + "process_credential_exclusions", "resolve_tenant", "validate_scope", "validate_subscription", diff --git a/sdk/identity/azure-identity/azure/identity/_internal/utils.py b/sdk/identity/azure-identity/azure/identity/_internal/utils.py index 248e5ef2e2da..b18393965c05 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/utils.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/utils.py @@ -133,3 +133,65 @@ def resolve_tenant( 'when creating the credential, or add "*" to additionally_allowed_tenants to allow ' "acquiring tokens for any tenant.".format(tenant_id) ) + + +def process_credential_exclusions(credential_config: dict, exclude_flags: dict, user_excludes: dict) -> dict: + """Process credential exclusions based on environment variable and user overrides. + + This method handles the AZURE_TOKEN_CREDENTIALS environment variable to determine + which credentials should be excluded from the credential chain, and then applies + any user-provided exclude overrides which take precedence over environment settings. + + :param credential_config: Configuration mapping for all available credentials, containing + exclude parameter names, environment names, and default exclude settings + :type credential_config: dict + :param exclude_flags: Dictionary of exclude flags for each credential (will be modified) + :type exclude_flags: dict + :param user_excludes: User-provided exclude overrides from constructor kwargs + :type user_excludes: dict + + :return: Dictionary of final exclude flags for each credential + :rtype: dict + + :raises ValueError: If token_credentials_env contains an invalid credential name + """ + # Handle AZURE_TOKEN_CREDENTIALS environment variable + token_credentials_env = os.environ.get(EnvironmentVariables.AZURE_TOKEN_CREDENTIALS, "").strip().lower() + + if token_credentials_env == "dev": + # In dev mode, use only developer credentials + dev_credentials = {"cli", "developer_cli", "powershell", "shared_token_cache"} + for cred_key in credential_config: + exclude_flags[cred_key] = cred_key not in dev_credentials + elif token_credentials_env == "prod": + # In prod mode, use only production credentials + prod_credentials = {"environment", "workload_identity", "managed_identity"} + for cred_key in credential_config: + exclude_flags[cred_key] = cred_key not in prod_credentials + elif token_credentials_env: + # If a specific credential is specified, exclude all others except the specified one + valid_credentials = {config["env_name"] for config in credential_config.values() if "env_name" in config} + + if token_credentials_env not in valid_credentials: + valid_values = ["dev", "prod"] + sorted(valid_credentials) + raise ValueError( + f"Invalid value for {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS}: {token_credentials_env}. " + f"Valid values are: {', '.join(valid_values)}." + ) + + # Find which credential was selected and exclude all others + selected_cred_key = None + for cred_key, config in credential_config.items(): + if config.get("env_name") == token_credentials_env: + selected_cred_key = cred_key + break + + for cred_key in credential_config: + exclude_flags[cred_key] = cred_key != selected_cred_key + + # Apply user-provided exclude flags (these override environment variable settings) + for cred_key, user_value in user_excludes.items(): + if user_value is not None: + exclude_flags[cred_key] = user_value + + return exclude_flags diff --git a/sdk/identity/azure-identity/azure/identity/_version.py b/sdk/identity/azure-identity/azure/identity/_version.py index 760523dec6db..7f08eadfa7d0 100644 --- a/sdk/identity/azure-identity/azure/identity/_version.py +++ b/sdk/identity/azure-identity/azure/identity/_version.py @@ -2,4 +2,4 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -VERSION = "1.23.1" +VERSION = "1.24.0b1" diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py index 08b3ac48bf01..2445db573ed0 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/default.py @@ -9,7 +9,7 @@ from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions from azure.core.credentials_async import AsyncTokenCredential, AsyncSupportsTokenInfo from ..._constants import EnvironmentVariables -from ..._internal import get_default_authority, normalize_authority, within_dac +from ..._internal import get_default_authority, normalize_authority, within_dac, process_credential_exclusions from .azure_cli import AzureCliCredential from .azd_cli import AzureDeveloperCliCredential from .azure_powershell import AzurePowerShellCredential @@ -89,7 +89,7 @@ class DefaultAzureCredential(ChainedTokenCredential): :caption: Create a DefaultAzureCredential. """ - def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statements + def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statements, too-many-locals if "tenant_id" in kwargs: raise TypeError("'tenant_id' is not supported in DefaultAzureCredential.") @@ -125,34 +125,68 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement process_timeout = kwargs.pop("process_timeout", 10) - token_credentials_env = os.environ.get(EnvironmentVariables.AZURE_TOKEN_CREDENTIALS, "").strip().lower() - exclude_workload_identity_credential = kwargs.pop("exclude_workload_identity_credential", False) - exclude_visual_studio_code_credential = kwargs.pop("exclude_visual_studio_code_credential", True) - exclude_developer_cli_credential = kwargs.pop("exclude_developer_cli_credential", False) - exclude_cli_credential = kwargs.pop("exclude_cli_credential", False) - exclude_environment_credential = kwargs.pop("exclude_environment_credential", False) - exclude_managed_identity_credential = kwargs.pop("exclude_managed_identity_credential", False) - exclude_shared_token_cache_credential = kwargs.pop("exclude_shared_token_cache_credential", False) - exclude_powershell_credential = kwargs.pop("exclude_powershell_credential", False) - - if token_credentials_env == "dev": - # In dev mode, use only developer credentials - exclude_environment_credential = True - exclude_managed_identity_credential = True - exclude_workload_identity_credential = True - elif token_credentials_env == "prod": - # In prod mode, use only production credentials - exclude_shared_token_cache_credential = True - exclude_visual_studio_code_credential = True - exclude_cli_credential = True - exclude_developer_cli_credential = True - exclude_powershell_credential = True - elif token_credentials_env != "": - # If the environment variable is set to something other than dev or prod, raise an error - raise ValueError( - f"Invalid value for {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS}: {token_credentials_env}. " - "Valid values are 'dev' or 'prod'." - ) + # Define credential configuration mapping (async version) + credential_config = { + "environment": { + "exclude_param": "exclude_environment_credential", + "env_name": "environmentcredential", + "default_exclude": False, + }, + "workload_identity": { + "exclude_param": "exclude_workload_identity_credential", + "env_name": "workloadidentitycredential", + "default_exclude": False, + }, + "managed_identity": { + "exclude_param": "exclude_managed_identity_credential", + "env_name": "managedidentitycredential", + "default_exclude": False, + }, + "shared_token_cache": { + "exclude_param": "exclude_shared_token_cache_credential", + "default_exclude": False, + }, + "visual_studio_code": { + "exclude_param": "exclude_visual_studio_code_credential", + "default_exclude": True, + }, + "cli": { + "exclude_param": "exclude_cli_credential", + "env_name": "azureclicredential", + "default_exclude": False, + }, + "developer_cli": { + "exclude_param": "exclude_developer_cli_credential", + "env_name": "azuredeveloperclicredential", + "default_exclude": False, + }, + "powershell": { + "exclude_param": "exclude_powershell_credential", + "env_name": "azurepowershellcredential", + "default_exclude": False, + }, + } + + # Extract user-provided exclude flags and set defaults + exclude_flags = {} + user_excludes = {} + for cred_key, config in credential_config.items(): + param_name = cast(str, config["exclude_param"]) + user_excludes[cred_key] = kwargs.pop(param_name, None) + exclude_flags[cred_key] = config["default_exclude"] + + # Process AZURE_TOKEN_CREDENTIALS environment variable and apply user overrides + exclude_flags = process_credential_exclusions(credential_config, exclude_flags, user_excludes) + + # Extract individual exclude flags for backward compatibility + exclude_environment_credential = exclude_flags["environment"] + exclude_workload_identity_credential = exclude_flags["workload_identity"] + exclude_managed_identity_credential = exclude_flags["managed_identity"] + exclude_shared_token_cache_credential = exclude_flags["shared_token_cache"] + exclude_visual_studio_code_credential = exclude_flags["visual_studio_code"] + exclude_cli_credential = exclude_flags["cli"] + exclude_developer_cli_credential = exclude_flags["developer_cli"] + exclude_powershell_credential = exclude_flags["powershell"] credentials: List[AsyncSupportsTokenInfo] = [] within_dac.set(True) diff --git a/sdk/identity/azure-identity/setup.py b/sdk/identity/azure-identity/setup.py index 0248c249363b..1fbbd5a55de8 100644 --- a/sdk/identity/azure-identity/setup.py +++ b/sdk/identity/azure-identity/setup.py @@ -38,7 +38,7 @@ url="https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/identity/azure-identity", keywords="azure, azure sdk", classifiers=[ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3", diff --git a/sdk/identity/azure-identity/tests/test_token_credentials_env.py b/sdk/identity/azure-identity/tests/test_token_credentials_env.py index 10ac3783a1df..a50d8f62e05a 100644 --- a/sdk/identity/azure-identity/tests/test_token_credentials_env.py +++ b/sdk/identity/azure-identity/tests/test_token_credentials_env.py @@ -29,7 +29,7 @@ def test_token_credentials_env_dev(): credential = DefaultAzureCredential() # Get the actual credential classes in the chain - actual_classes = {c.__class__ for c in credential.credentials} + actual_classes = [c.__class__ for c in credential.credentials] # All dev credentials should be present (if supported) if SharedTokenCacheCredential.supported(): @@ -40,6 +40,9 @@ def test_token_credentials_env_dev(): assert AzureDeveloperCliCredential in actual_classes assert AzurePowerShellCredential in actual_classes + # Assert no duplicates + assert len(actual_classes) == len(set(actual_classes)) + # Production credentials should NOT be present for cred_class in prod_credentials: if cred_class == WorkloadIdentityCredential: @@ -60,18 +63,10 @@ def test_token_credentials_env_prod(): } with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "prod"}, clear=False): - # Print to verify the environment variable is set in the test - print(f"AZURE_TOKEN_CREDENTIALS={os.environ.get(EnvironmentVariables.AZURE_TOKEN_CREDENTIALS)}") - credential = DefaultAzureCredential() # Get the actual credential classes in the chain - actual_classes = {c.__class__ for c in credential.credentials} - - # Print which credentials are actually in the chain - print("Credentials in chain:") - for cls in actual_classes: - print(f" - {cls.__name__}") + actual_classes = [c.__class__ for c in credential.credentials] # Production credentials should be present assert EnvironmentCredential in actual_classes @@ -81,6 +76,9 @@ def test_token_credentials_env_prod(): if all(os.environ.get(var) for var in EnvironmentVariables.WORKLOAD_IDENTITY_VARS): assert WorkloadIdentityCredential in actual_classes + # Assert no duplicates + assert len(actual_classes) == len(set(actual_classes)) + # Developer credentials should NOT be present for cred_class in dev_credentials: assert cred_class not in actual_classes @@ -93,7 +91,7 @@ def test_token_credentials_env_case_insensitive(): credential = DefaultAzureCredential() # Get the actual credential classes in the chain - actual_classes = {c.__class__ for c in credential.credentials} + actual_classes = [c.__class__ for c in credential.credentials] # EnvironmentCredential (prod) should not be present assert EnvironmentCredential not in actual_classes @@ -113,6 +111,141 @@ def test_token_credentials_env_invalid(): def test_token_credentials_env_with_exclude(): with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "prod"}, clear=False): credential = DefaultAzureCredential(exclude_environment_credential=True) - actual_classes = {c.__class__ for c in credential.credentials} + actual_classes = [c.__class__ for c in credential.credentials] assert EnvironmentCredential not in actual_classes + + +def test_token_credentials_env_workload_identity_credential(): + """With AZURE_TOKEN_CREDENTIALS=WorkloadIdentityCredential, only WorkloadIdentityCredential should be used""" + + with patch.dict( + "os.environ", + { + EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "WorkloadIdentityCredential", + EnvironmentVariables.AZURE_AUTHORITY_HOST: "https://login.microsoftonline.com", + EnvironmentVariables.AZURE_TENANT_ID: "tenant-id", + EnvironmentVariables.AZURE_CLIENT_ID: "client-id", + EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE: "/tmp/token", + }, + clear=False, + ): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only WorkloadIdentityCredential should be present + assert WorkloadIdentityCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_environment_credential(): + """With AZURE_TOKEN_CREDENTIALS=EnvironmentCredential, only EnvironmentCredential should be used""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "EnvironmentCredential"}, clear=False): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only EnvironmentCredential should be present + assert EnvironmentCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_managed_identity_credential(): + """With AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential, only ManagedIdentityCredential should be used""" + + with patch.dict( + "os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "ManagedIdentityCredential"}, clear=False + ): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only ManagedIdentityCredential should be present + assert ManagedIdentityCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_azure_cli_credential(): + """With AZURE_TOKEN_CREDENTIALS=AzureCliCredential, only AzureCliCredential should be used""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "AzureCliCredential"}, clear=False): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only AzureCliCredential should be present + assert AzureCliCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_specific_credential_case_insensitive(): + """Specific credential names should be case insensitive""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "environmentcredential"}, clear=False): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only EnvironmentCredential should be present + assert EnvironmentCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_invalid_specific_credential(): + """Invalid specific credential name should raise an error""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "InvalidCredential"}, clear=False): + with pytest.raises(ValueError) as exc_info: + credential = DefaultAzureCredential() + + error_msg = str(exc_info.value) + assert "Invalid value" in error_msg + + # Test case: specific credential that is not supported + # For example, SharedTokenCacheCredential is not supported in this context + with patch.dict( + "os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "sharedtokencachecredential"}, clear=True + ): + with pytest.raises(ValueError): + DefaultAzureCredential() + + error_msg = str(exc_info.value) + assert "Invalid value" in error_msg + + +def test_user_exclude_flags_override_env_var(): + """User-provided exclude flags should take precedence over AZURE_TOKEN_CREDENTIALS environment variable""" + + # Test case 1: env var says use specific credential, but user excludes it (should result in empty chain) + with patch.dict( + "os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "azurepowershellcredential"}, clear=False + ): + with pytest.raises(ValueError, match="at least one credential is required"): + DefaultAzureCredential(exclude_powershell_credential=True) + + # Test case 2: env var says use specific credential, user includes additional credential + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "environmentcredential"}, clear=False): + credential = DefaultAzureCredential(exclude_powershell_credential=False) + actual_classes = [c.__class__ for c in credential.credentials] + + # Both EnvironmentCredential and AzurePowerShellCredential should be present + assert EnvironmentCredential in actual_classes + assert AzurePowerShellCredential in actual_classes + + # Test case 3: env var says "dev" mode, user excludes a dev credential + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "dev"}, clear=False): + credential = DefaultAzureCredential(exclude_cli_credential=True) + actual_classes = [c.__class__ for c in credential.credentials] + + # AzureCliCredential should NOT be present despite "dev" mode + assert AzureCliCredential not in actual_classes + # Other dev credentials should still be present + assert AzurePowerShellCredential in actual_classes + assert AzureDeveloperCliCredential in actual_classes diff --git a/sdk/identity/azure-identity/tests/test_token_credentials_env_async.py b/sdk/identity/azure-identity/tests/test_token_credentials_env_async.py index 9db1193bd745..17cd9df98072 100644 --- a/sdk/identity/azure-identity/tests/test_token_credentials_env_async.py +++ b/sdk/identity/azure-identity/tests/test_token_credentials_env_async.py @@ -29,7 +29,7 @@ def test_token_credentials_env_dev(): credential = DefaultAzureCredential() # Get the actual credential classes in the chain - actual_classes = {c.__class__ for c in credential.credentials} + actual_classes = [c.__class__ for c in credential.credentials] # All dev credentials should be present (if supported) if SharedTokenCacheCredential.supported(): @@ -40,6 +40,9 @@ def test_token_credentials_env_dev(): assert AzureDeveloperCliCredential in actual_classes assert AzurePowerShellCredential in actual_classes + # Assert no duplicates + assert len(actual_classes) == len(set(actual_classes)) + # Production credentials should NOT be present for cred_class in prod_credentials: if cred_class == WorkloadIdentityCredential: @@ -60,18 +63,10 @@ def test_token_credentials_env_prod(): } with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "prod"}, clear=False): - # Print to verify the environment variable is set in the test - print(f"AZURE_TOKEN_CREDENTIALS={os.environ.get(EnvironmentVariables.AZURE_TOKEN_CREDENTIALS)}") - credential = DefaultAzureCredential() # Get the actual credential classes in the chain - actual_classes = {c.__class__ for c in credential.credentials} - - # Print which credentials are actually in the chain - print("Credentials in chain:") - for cls in actual_classes: - print(f" - {cls.__name__}") + actual_classes = [c.__class__ for c in credential.credentials] # Production credentials should be present assert EnvironmentCredential in actual_classes @@ -81,6 +76,9 @@ def test_token_credentials_env_prod(): if all(os.environ.get(var) for var in EnvironmentVariables.WORKLOAD_IDENTITY_VARS): assert WorkloadIdentityCredential in actual_classes + # Assert no duplicates + assert len(actual_classes) == len(set(actual_classes)) + # Developer credentials should NOT be present for cred_class in dev_credentials: assert cred_class not in actual_classes @@ -93,7 +91,7 @@ def test_token_credentials_env_case_insensitive(): credential = DefaultAzureCredential() # Get the actual credential classes in the chain - actual_classes = {c.__class__ for c in credential.credentials} + actual_classes = [c.__class__ for c in credential.credentials] # EnvironmentCredential (prod) should not be present assert EnvironmentCredential not in actual_classes @@ -113,6 +111,141 @@ def test_token_credentials_env_invalid(): def test_token_credentials_env_with_exclude(): with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "prod"}, clear=False): credential = DefaultAzureCredential(exclude_environment_credential=True) - actual_classes = {c.__class__ for c in credential.credentials} + actual_classes = [c.__class__ for c in credential.credentials] assert EnvironmentCredential not in actual_classes + + +def test_token_credentials_env_workload_identity_credential(): + """With AZURE_TOKEN_CREDENTIALS=WorkloadIdentityCredential, only WorkloadIdentityCredential should be used""" + + with patch.dict( + "os.environ", + { + EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "WorkloadIdentityCredential", + EnvironmentVariables.AZURE_AUTHORITY_HOST: "https://login.microsoftonline.com", + EnvironmentVariables.AZURE_TENANT_ID: "tenant-id", + EnvironmentVariables.AZURE_CLIENT_ID: "client-id", + EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE: "/tmp/token", + }, + clear=False, + ): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only WorkloadIdentityCredential should be present + assert WorkloadIdentityCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_environment_credential(): + """With AZURE_TOKEN_CREDENTIALS=EnvironmentCredential, only EnvironmentCredential should be used""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "EnvironmentCredential"}, clear=False): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only EnvironmentCredential should be present + assert EnvironmentCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_managed_identity_credential(): + """With AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential, only ManagedIdentityCredential should be used""" + + with patch.dict( + "os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "ManagedIdentityCredential"}, clear=False + ): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only ManagedIdentityCredential should be present + assert ManagedIdentityCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_azure_cli_credential(): + """With AZURE_TOKEN_CREDENTIALS=AzureCliCredential, only AzureCliCredential should be used""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "AzureCliCredential"}, clear=False): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only AzureCliCredential should be present + assert AzureCliCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_specific_credential_case_insensitive(): + """Specific credential names should be case insensitive""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "environmentcredential"}, clear=False): + credential = DefaultAzureCredential() + + # Get the actual credential classes in the chain + actual_classes = [c.__class__ for c in credential.credentials] + + # Only EnvironmentCredential should be present + assert EnvironmentCredential in actual_classes + assert len(actual_classes) == 1 + + +def test_token_credentials_env_invalid_specific_credential(): + """Invalid specific credential name should raise an error""" + + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "InvalidCredential"}, clear=False): + with pytest.raises(ValueError) as exc_info: + credential = DefaultAzureCredential() + + error_msg = str(exc_info.value) + assert "Invalid value" in error_msg + + # Test case: specific credential that is not supported + # For example, SharedTokenCacheCredential is not supported in this context + with patch.dict( + "os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "sharedtokencachecredential"}, clear=True + ): + with pytest.raises(ValueError): + DefaultAzureCredential() + + error_msg = str(exc_info.value) + assert "Invalid value" in error_msg + + +def test_user_exclude_flags_override_env_var(): + """User-provided exclude flags should take precedence over AZURE_TOKEN_CREDENTIALS environment variable""" + + # Test case 1: env var says use specific credential, but user excludes it (should result in empty chain) + with patch.dict( + "os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "azurepowershellcredential"}, clear=False + ): + with pytest.raises(ValueError, match="at least one credential is required"): + DefaultAzureCredential(exclude_powershell_credential=True) + + # Test case 2: env var says use specific credential, user includes additional credential + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "environmentcredential"}, clear=False): + credential = DefaultAzureCredential(exclude_powershell_credential=False) + actual_classes = [c.__class__ for c in credential.credentials] + + # Both EnvironmentCredential and AzurePowerShellCredential should be present + assert EnvironmentCredential in actual_classes + assert AzurePowerShellCredential in actual_classes + + # Test case 3: env var says "dev" mode, user excludes a dev credential + with patch.dict("os.environ", {EnvironmentVariables.AZURE_TOKEN_CREDENTIALS: "dev"}, clear=False): + credential = DefaultAzureCredential(exclude_cli_credential=True) + actual_classes = [c.__class__ for c in credential.credentials] + + # AzureCliCredential should NOT be present despite "dev" mode + assert AzureCliCredential not in actual_classes + # Other dev credentials should still be present + assert AzurePowerShellCredential in actual_classes + assert AzureDeveloperCliCredential in actual_classes