diff --git a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py index af9763aa727..68fe2f3d892 100644 --- a/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py +++ b/sdk/identity/azure-identity-broker/azure/identity/broker/_browser.py @@ -58,7 +58,8 @@ class InteractiveBrowserBrokerCredential(_InteractiveBrowserCredential): are required to also provide its window handle, so that the sign in UI window will properly pop up on top of your window. :keyword bool use_default_broker_account: Enables automatically using the default broker account for - authentication instead of prompting the user with an account picker. Defaults to False. + authentication instead of prompting the user with an account picker. This is currently only supported on Windows + and WSL. Defaults to False. :keyword bool enable_msa_passthrough: Determines whether Microsoft Account (MSA) passthrough is enabled. Note, this is only needed for select legacy first-party applications. Defaults to False. :keyword bool disable_instance_discovery: Determines whether or not instance discovery is performed when attempting @@ -78,6 +79,7 @@ def __init__(self, **kwargs: Any) -> None: self._parent_window_handle = kwargs.pop("parent_window_handle", None) self._enable_msa_passthrough = kwargs.pop("enable_msa_passthrough", False) self._use_default_broker_account = kwargs.pop("use_default_broker_account", False) + self._disable_interactive_fallback = kwargs.pop("disable_interactive_fallback", False) super().__init__(**kwargs) @wrap_exceptions @@ -93,6 +95,7 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: http_method=pop["resource_request_method"], url=pop["resource_request_url"], nonce=pop["nonce"] ) if sys.platform.startswith("win") or is_wsl(): + result = {} if self._use_default_broker_account: try: result = app.acquire_token_interactive( @@ -110,6 +113,10 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: return result except socket.error: pass + + if self._disable_interactive_fallback: + self._check_result(result) + try: result = app.acquire_token_interactive( scopes=scopes_list, @@ -124,14 +131,8 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: ) except socket.error as ex: raise CredentialUnavailableError(message="Couldn't start an HTTP server.") from ex - if "access_token" not in result and "error_description" in result: - if within_dac.get(): - raise CredentialUnavailableError(message=result["error_description"]) - raise ClientAuthenticationError(message=result.get("error_description")) - if "access_token" not in result: - if within_dac.get(): - raise CredentialUnavailableError(message="Failed to authenticate user") - raise ClientAuthenticationError(message="Failed to authenticate user") + + self._check_result(result) else: try: result = app.acquire_token_interactive( @@ -157,16 +158,19 @@ def _request_token(self, *scopes: str, **kwargs: Any) -> Dict: parent_window_handle=self._parent_window_handle, enable_msa_passthrough=self._enable_msa_passthrough, ) - if "access_token" in result: - return result - if "error_description" in result: - if within_dac.get(): - # pylint: disable=raise-missing-from - raise CredentialUnavailableError(message=result["error_description"]) - # pylint: disable=raise-missing-from - raise ClientAuthenticationError(message=result.get("error_description")) + self._check_result(result) return result + def _check_result(self, result: Dict[str, Any]) -> None: + if "access_token" not in result and "error_description" in result: + if within_dac.get(): + raise CredentialUnavailableError(message=result["error_description"]) + raise ClientAuthenticationError(message=result.get("error_description")) + if "access_token" not in result: + if within_dac.get(): + raise CredentialUnavailableError(message="Failed to authenticate user") + raise ClientAuthenticationError(message="Failed to authenticate user") + def _get_app(self, **kwargs: Any) -> msal.ClientApplication: tenant_id = resolve_tenant( self._tenant_id, additionally_allowed_tenants=self._additionally_allowed_tenants, **kwargs diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index 93bffec1847..1f6de60cde3 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -8,7 +8,7 @@ - Valid values are `EnvironmentCredential`, `WorkloadIdentityCredential`, `ManagedIdentityCredential`, `AzureCliCredential`, `AzurePowershellCredential`, `AzureDeveloperCliCredential`, and `InteractiveBrowserCredential`. ([#41709](https://github.com/Azure/azure-sdk-for-python/pull/41709)) - Re-enabled `VisualStudioCodeCredential` - Previously deprecated `VisualStudioCodeCredential` has been re-implemented to work with the VS Code Azure Resources extension instead of the deprecated Azure Account extension. This requires the `azure-identity-broker` package to be installed for authentication. ([#41822](https://github.com/Azure/azure-sdk-for-python/pull/41822)) - `VisualStudioCodeCredential` is now included in the `DefaultAzureCredential` token chain by default. - +- `DefaultAzureCredential` now supports authentication with the currently signed-in Windows account, provided the `azure-identity-broker` package is installed. This auth mechanism is added at the end of the `DefaultAzureCredential` credential chain. ([#40335](https://github.com/Azure/azure-sdk-for-python/pull/40335)) ### Breaking Changes diff --git a/sdk/identity/azure-identity/README.md b/sdk/identity/azure-identity/README.md index 49a4c39b603..a6d4e837cdc 100644 --- a/sdk/identity/azure-identity/README.md +++ b/sdk/identity/azure-identity/README.md @@ -321,7 +321,7 @@ The Azure Identity library offers both in-memory and persistent disk caching. Fo ## Brokered authentication -An authentication broker is an application that runs on a user’s machine and manages the authentication handshakes and token maintenance for connected accounts. Currently, only the Windows Web Account Manager (WAM) is supported. To enable support, use the [`azure-identity-broker`][azure_identity_broker] package. For details on authenticating using WAM, see the [broker plugin documentation][azure_identity_broker_readme]. +An authentication broker is an application that runs on a user’s machine and manages the authentication handshakes and token maintenance for connected accounts. Currently, only the Windows Web Account Manager (WAM) is supported. Authentication via WAM is used by `DefaultAzureCredential` to enable secure sign-in. To enable support, use the [`azure-identity-broker`][azure_identity_broker] package. For details on authenticating using WAM, see the [broker plugin documentation][azure_identity_broker_readme]. ## Troubleshooting diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/broker.py b/sdk/identity/azure-identity/azure/identity/_credentials/broker.py new file mode 100644 index 00000000000..d7ead3f9577 --- /dev/null +++ b/sdk/identity/azure-identity/azure/identity/_credentials/broker.py @@ -0,0 +1,79 @@ +# ------------------------------------ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# ------------------------------------ +import sys +from typing import Any + +import msal +from azure.core.credentials import AccessToken, AccessTokenInfo, SupportsTokenInfo +from .._exceptions import CredentialUnavailableError +from .._internal.utils import get_broker_credential, is_wsl + + +class BrokerCredential(SupportsTokenInfo): + """A broker credential that handles prerequisite checking and falls back appropriately. + + This credential checks if the azure-identity-broker package is available and the platform + is supported. If both conditions are met, it uses the real broker credential. Otherwise, + it raises CredentialUnavailableError with an appropriate message. + """ + + def __init__(self, **kwargs: Any) -> None: + + self._tenant_id = kwargs.pop("tenant_id", None) + self._client_id = kwargs.pop("client_id", None) + self._broker_credential = None + self._unavailable_message = None + + # Check prerequisites and initialize the appropriate credential + broker_credential_class = get_broker_credential() + if broker_credential_class and (sys.platform.startswith("win") or is_wsl()): + # The silent auth flow for brokered auth is available on Windows/WSL with the broker package + try: + broker_credential_args = { + "tenant_id": self._tenant_id, + "parent_window_handle": msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE, + "use_default_broker_account": True, + "disable_interactive_fallback": True, + **kwargs, + } + if self._client_id: + broker_credential_args["client_id"] = self._client_id + self._broker_credential = broker_credential_class(**broker_credential_args) + except Exception as ex: # pylint: disable=broad-except + self._unavailable_message = f"InteractiveBrowserBrokerCredential initialization failed: {ex}" + else: + # Determine the specific reason for unavailability + if broker_credential_class is None: + self._unavailable_message = ( + "InteractiveBrowserBrokerCredential unavailable. " + "The 'azure-identity-broker' package is required to use brokered authentication." + ) + else: + self._unavailable_message = ( + "InteractiveBrowserBrokerCredential unavailable. " + "Brokered authentication is only supported on Windows and WSL platforms." + ) + + def get_token(self, *scopes: str, **kwargs: Any) -> AccessToken: + if self._broker_credential: + return self._broker_credential.get_token(*scopes, **kwargs) + raise CredentialUnavailableError(message=self._unavailable_message) + + def get_token_info(self, *scopes: str, **kwargs: Any) -> AccessTokenInfo: + if self._broker_credential: + return self._broker_credential.get_token_info(*scopes, **kwargs) + raise CredentialUnavailableError(message=self._unavailable_message) + + def __enter__(self) -> "BrokerCredential": + if self._broker_credential: + self._broker_credential.__enter__() + return self + + def __exit__(self, *args): + if self._broker_credential: + self._broker_credential.__exit__(*args) + + def close(self) -> None: + self.__exit__() diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/default.py b/sdk/identity/azure-identity/azure/identity/_credentials/default.py index 316b202d077..436d3535ded 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/default.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/default.py @@ -8,8 +8,9 @@ from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions, SupportsTokenInfo, TokenCredential from .._constants import EnvironmentVariables -from .._internal import get_default_authority, normalize_authority, within_dac, process_credential_exclusions +from .._internal.utils import get_default_authority, normalize_authority, within_dac, process_credential_exclusions from .azure_powershell import AzurePowerShellCredential +from .broker import BrokerCredential from .browser import InteractiveBrowserCredential from .chained import ChainedTokenCredential from .environment import EnvironmentCredential @@ -42,6 +43,8 @@ class DefaultAzureCredential(ChainedTokenCredential): 5. The identity currently logged in to the Azure CLI. 6. The identity currently logged in to Azure PowerShell. 7. The identity currently logged in to the Azure Developer CLI. + 8. Brokered authentication. On Windows and WSL only, this uses the default account logged in via + Web Account Manager (WAM) if the `azure-identity-broker` package is installed. This default behavior is configurable with keyword arguments. @@ -64,9 +67,13 @@ class DefaultAzureCredential(ChainedTokenCredential): **False**. :keyword bool exclude_interactive_browser_credential: Whether to exclude interactive browser authentication (see :class:`~azure.identity.InteractiveBrowserCredential`). Defaults to **True**. + :keyword bool exclude_broker_credential: Whether to exclude the broker credential from the credential chain. + Defaults to **False**. :keyword str interactive_browser_tenant_id: Tenant ID to use when authenticating a user through :class:`~azure.identity.InteractiveBrowserCredential`. Defaults to the value of environment variable AZURE_TENANT_ID, if any. If unspecified, users will authenticate in their home tenants. + :keyword str broker_tenant_id: The tenant ID to use when using brokered authentication. Defaults to the value of + environment variable AZURE_TENANT_ID, if any. If unspecified, users will authenticate in their home tenants. :keyword str managed_identity_client_id: The client ID of a user-assigned managed identity. Defaults to the value of the environment variable AZURE_CLIENT_ID, if any. If not specified, a system-assigned identity will be used. :keyword str workload_identity_client_id: The client ID of an identity assigned to the pod. Defaults to the value @@ -75,6 +82,8 @@ class DefaultAzureCredential(ChainedTokenCredential): Defaults to the value of environment variable AZURE_TENANT_ID, if any. :keyword str interactive_browser_client_id: The client ID to be used in interactive browser credential. If not specified, users will authenticate to an Azure development application. + :keyword str broker_client_id: The client ID to be used in brokered authentication. If not specified, users will + authenticate to an Azure development application. :keyword str shared_cache_username: Preferred username for :class:`~azure.identity.SharedTokenCacheCredential`. Defaults to the value of environment variable AZURE_USERNAME, if any. :keyword str shared_cache_tenant_id: Preferred tenant for :class:`~azure.identity.SharedTokenCacheCredential`. @@ -117,6 +126,9 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement ) interactive_browser_client_id = kwargs.pop("interactive_browser_client_id", None) + broker_tenant_id = kwargs.pop("broker_tenant_id", os.environ.get(EnvironmentVariables.AZURE_TENANT_ID)) + broker_client_id = kwargs.pop("broker_client_id", None) + shared_cache_username = kwargs.pop("shared_cache_username", os.environ.get(EnvironmentVariables.AZURE_USERNAME)) shared_cache_tenant_id = kwargs.pop( "shared_cache_tenant_id", os.environ.get(EnvironmentVariables.AZURE_TENANT_ID) @@ -147,6 +159,7 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement }, "visual_studio_code": { "exclude_param": "exclude_visual_studio_code_credential", + "env_name": "visualstudiocodecredential", "default_exclude": False, }, "cli": { @@ -169,6 +182,10 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement "env_name": "interactivebrowsercredential", "default_exclude": True, }, + "broker": { + "exclude_param": "exclude_broker_credential", + "default_exclude": False, + }, } # Extract user-provided exclude flags and set defaults @@ -192,6 +209,7 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement exclude_developer_cli_credential = exclude_flags["developer_cli"] exclude_powershell_credential = exclude_flags["powershell"] exclude_interactive_browser_credential = exclude_flags["interactive_browser"] + exclude_broker_credential = exclude_flags["broker"] credentials: List[SupportsTokenInfo] = [] within_dac.set(True) @@ -242,6 +260,12 @@ def __init__(self, **kwargs: Any) -> None: # pylint: disable=too-many-statement ) else: credentials.append(InteractiveBrowserCredential(tenant_id=interactive_browser_tenant_id, **kwargs)) + if not exclude_broker_credential: + broker_credential_args = {"tenant_id": broker_tenant_id, **kwargs} + if broker_client_id: + broker_credential_args["client_id"] = broker_client_id + credentials.append(BrokerCredential(**broker_credential_args)) + within_dac.set(False) super(DefaultAzureCredential, self).__init__(*credentials) diff --git a/sdk/identity/azure-identity/azure/identity/_internal/utils.py b/sdk/identity/azure-identity/azure/identity/_internal/utils.py index 2303906ec4e..e60bffdd3a6 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/utils.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/utils.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. # ------------------------------------ import os +import platform import logging from contextvars import ContextVar from string import ascii_letters, digits @@ -209,3 +210,11 @@ def get_broker_credential() -> Optional[type]: return InteractiveBrowserBrokerCredential except ImportError: return None + + +def is_wsl() -> bool: + # This is how MSAL checks for WSL. + uname = platform.uname() + platform_name = getattr(uname, "system", uname[0]).lower() + release = getattr(uname, "release", uname[2]).lower() + return platform_name == "linux" and "microsoft" in release diff --git a/sdk/identity/azure-identity/tests/test_default.py b/sdk/identity/azure-identity/tests/test_default.py index b77bfbcbad2..d2f4bc1f0bb 100644 --- a/sdk/identity/azure-identity/tests/test_default.py +++ b/sdk/identity/azure-identity/tests/test_default.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. # ------------------------------------ import os +import sys from azure.core.credentials import AccessToken, AccessTokenInfo from azure.identity import ( @@ -19,6 +20,7 @@ from azure.identity._credentials.azure_cli import AzureCliCredential from azure.identity._credentials.azd_cli import AzureDeveloperCliCredential from azure.identity._credentials.managed_identity import ManagedIdentityCredential +from azure.identity._internal.utils import is_wsl import pytest from urllib.parse import urlparse @@ -175,12 +177,25 @@ def assert_credentials_not_present(chain, *excluded_credential_classes): credential = DefaultAzureCredential(exclude_developer_cli_credential=True) assert_credentials_not_present(credential, AzureDeveloperCliCredential) + # test excluding broker credential + credential = DefaultAzureCredential(exclude_broker_credential=True) + from azure.identity._credentials.broker import BrokerCredential + + assert_credentials_not_present(credential, BrokerCredential) + # interactive auth is excluded by default credential = DefaultAzureCredential(exclude_interactive_browser_credential=False) actual = {c.__class__ for c in credential.credentials} default = {c.__class__ for c in DefaultAzureCredential().credentials} assert actual - default == {InteractiveBrowserCredential} + # broker credential is included by default + credential = DefaultAzureCredential() + from azure.identity._credentials.broker import BrokerCredential + + actual = {c.__class__ for c in credential.credentials} + assert BrokerCredential in actual, "BrokerCredential should be included in DefaultAzureCredential by default" + @pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS) def test_shared_cache_tenant_id(get_token_method): @@ -432,3 +447,60 @@ def test_validate_cloud_shell_credential_in_dac(): DefaultAzureCredential(identity_config={"client_id": "foo"}) DefaultAzureCredential(identity_config={"object_id": "foo"}) DefaultAzureCredential(identity_config={"resource_id": "foo"}) + + +@pytest.mark.skipif(not sys.platform.startswith("win") and not is_wsl(), reason="tests Windows-specific behavior") +def test_broker_credential(): + """Test that DefaultAzureCredential uses the broker credential when available""" + with patch("azure.identity.broker.InteractiveBrowserBrokerCredential") as mock_credential: + credential = DefaultAzureCredential() + # The broker credential should be in the chain + broker_credentials = [c for c in credential.credentials if c.__class__.__name__ == "BrokerCredential"] + assert len(broker_credentials) == 1, "BrokerCredential should be in the chain" + # InteractiveBrowserBrokerCredential should be instantiated by BrokerCredential + assert mock_credential.call_count >= 1, "InteractiveBrowserBrokerCredential should be instantiated" + + +def test_broker_credential_client_id(): + """Test that DefaultAzureCredential allows configuring a client ID for BrokerCredential""" + + client_id = "broker-client-id" + credential = DefaultAzureCredential(broker_client_id=client_id) + broker_credentials = [c for c in credential.credentials if c.__class__.__name__ == "BrokerCredential"] + assert ( + len(broker_credentials) == 1 + ), "BrokerCredential should be in the chain even when broker package is not installed" + broker_credential = broker_credentials[0] + assert broker_credential._client_id == client_id, "Credential should be instantiated with the specified client ID" + + +def test_broker_credential_tenant_id(): + """Test that DefaultAzureCredential allows configuring a tenant ID for BrokerCredential""" + + tenant_id = "broker-tenant-id" + + credential = DefaultAzureCredential(broker_tenant_id=tenant_id) + broker_credentials = [c for c in credential.credentials if c.__class__.__name__ == "BrokerCredential"] + assert ( + len(broker_credentials) == 1 + ), "BrokerCredential should be in the chain even when broker package is not installed" + broker_credential = broker_credentials[0] + assert broker_credential._tenant_id == tenant_id, "Credential should be instantiated with the specified tenant ID" + + +def test_broker_credential_requirements_not_installed(): + """Test that DefaultAzureCredential includes BrokerCredential even when broker package is not installed""" + + # Mock the get_broker_credential function to return None (simulating package not installed) + with patch.dict("sys.modules", {"azure.identity.broker": None}): + credential = DefaultAzureCredential() + # The broker credential should still be in the chain + broker_credentials = [c for c in credential.credentials if c.__class__.__name__ == "BrokerCredential"] + assert ( + len(broker_credentials) == 1 + ), "BrokerCredential should be in the chain even when broker package is not installed" + + # Test that the broker credential raises CredentialUnavailableError + broker_cred = broker_credentials[0] + with pytest.raises(CredentialUnavailableError) as exc_info: + broker_cred.get_token_info("https://management.azure.com/.default")