diff --git a/sdk/identity/azure-identity/CHANGELOG.md b/sdk/identity/azure-identity/CHANGELOG.md index 8e2db68f9c68..31d0df571455 100644 --- a/sdk/identity/azure-identity/CHANGELOG.md +++ b/sdk/identity/azure-identity/CHANGELOG.md @@ -5,18 +5,20 @@ ### Features Added - Credential HTTP pipeline policies can now be overridden via the `headers_policy`, `logging_policy`, `http_logging_policy`, `proxy_policy`, `user_agent_policy`, `custom_hook_policy`, and `retry_policy` keyword arguments when constructing credentials. The `per_retry_policies` and `per_call_policies` are also now supported. This allows users to inject custom policies or override settings of built-in policies. ([#46072](https://github.com/Azure/azure-sdk-for-python/pull/46072)) +- `ManagedIdentityCredential` now supports user-assigned managed identities on Azure Arc-enabled servers. An identity can be selected by client ID, object ID, or resource ID. Token responses that do not confirm the requested identity are rejected. ### Breaking Changes ### Bugs Fixed - Fixed `AzureDeveloperCliCredential` to correctly parse error messages from Azure Developer CLI v1.23.7 and later, which previously caused raw JSON to surface in `ClientAuthenticationError` instead of the underlying error text. +- Fixed synchronous Service Fabric managed identity authentication with MSAL 1.38.0 and later. Service Fabric now uses a `requests.Session`; a supplied `transport` is ignored with a warning. ### Other Changes - Added `RequestIdPolicy` to the default pipeline policies to ensure a unique `x-ms-client-request-id` header is sent with each request. ([#46070](https://github.com/Azure/azure-sdk-for-python/pull/46070)) - `CertificateCredential` now passes the PEM private_key to MSAL as a str rather than bytes, matching MSAL's documented `client_credential` contract. ([#46801](https://github.com/Azure/azure-sdk-for-python/pull/46801)) -- Temporarily constrained `msal` to `<1.38.0` because MSAL 1.38 is incompatible with the Azure Core-backed transport used by synchronous Service Fabric managed identity authentication. +- Bumped the minimum dependency on `msal` to `>=1.38.0`. ## 1.25.3 (2026-03-12) diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/azure_arc.py b/sdk/identity/azure-identity/azure/identity/_credentials/azure_arc.py index b43edaecb7f9..a404cd3138b9 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/azure_arc.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/azure_arc.py @@ -20,14 +20,10 @@ def get_unavailable_message(self, desc: str = "") -> str: def _get_request(url: str, scope: str, identity_config: Dict) -> HttpRequest: - if identity_config: - raise ClientAuthenticationError( - message="User assigned managed identities are not supported by Azure Arc. To authenticate with the system " - "assigned identity omit the client id when constructing the credential, and if authenticating with " - "DefaultAzureCredential ensure the AZURE_CLIENT_ID environment variable is not set." - ) - - return HttpRequest("GET", url, params=dict({"api-version": "2020-06-01", "resource": scope}, **identity_config)) + params = {"api-version": "2020-06-01", "resource": scope} + # Azure Arc requires the IMDS msi_res_id spelling for resource ID requests + params.update({"msi_res_id" if name == "resource_id" else name: value for name, value in identity_config.items()}) + return HttpRequest("GET", url, params=params) def _get_secret_key(response: PipelineResponse) -> str: @@ -78,6 +74,32 @@ def _get_key_file_path() -> str: raise ValueError(f"Azure Arc MSI is not supported on this platform {sys.platform}") +def _validate_user_assigned_identity(identity_config: Dict, content: Dict) -> None: + """Validates that Azure Arc returned the requested user-assigned identity token. + + :param dict identity_config: The configuration of the requested user-assigned identity. + :param dict content: The deserialized response content. + :raises ClientAuthenticationError: If the response content is invalid. + """ + if not identity_config: + return + + response_fields = {"client_id": "client_id", "object_id": "object_id", "resource_id": "msi_res_id"} + + for identity_type, response_field in response_fields.items(): + if identity_type not in identity_config: + continue + returned_id = content.get(response_field) + if identity_type == "resource_id": + returned_id = returned_id or content.get("mi_res_id") + if not returned_id or str(identity_config[identity_type]).lower() != returned_id.lower(): + raise ClientAuthenticationError( + message="Azure Arc did not confirm the requested user-assigned managed identity " + "in the token response. The agent likely does not support user-assigned " + "managed identities and returned the system-assigned identity." + ) + + def _validate_key_file(file_path: str) -> None: """Validates that a given Azure Arc MSI file path is valid for use. diff --git a/sdk/identity/azure-identity/azure/identity/_credentials/service_fabric.py b/sdk/identity/azure-identity/azure/identity/_credentials/service_fabric.py index b79234ee4adf..b5d7f6e3d056 100644 --- a/sdk/identity/azure-identity/azure/identity/_credentials/service_fabric.py +++ b/sdk/identity/azure-identity/azure/identity/_credentials/service_fabric.py @@ -4,8 +4,11 @@ # ------------------------------------ import functools import os +import warnings from typing import Dict, Optional, Any +import requests + from azure.core.credentials import AccessToken, AccessTokenInfo, TokenRequestOptions from azure.core.exceptions import ClientAuthenticationError from azure.core.rest import HttpRequest @@ -25,8 +28,28 @@ class ServiceFabricCredential(MsalManagedIdentityClient): def get_unavailable_message(self, desc: str = "") -> str: return f"Service Fabric managed identity configuration not found in environment. {desc}" + def _create_http_client(self, **kwargs: Any) -> requests.Session: + ignored_options = [ + name + for name in ("transport", "raw_request_hook", "raw_response_hook", "retry_policy", "proxy_policy") + if kwargs.get(name) is not None + ] + if ignored_options: + warnings.warn( + "The following arguments are ignored for synchronous Service Fabric managed identity credential " + "because MSAL >= 1.38.0 requires a requests.Session and does not support Azure Core pipeline " + "customization: {}.".format(", ".join(ignored_options)), + UserWarning, + stacklevel=3, + ) + return requests.Session() # Service Fabric requires requests.Session for MSAL >= 1.38.0, temporary workaround + def get_token( - self, *scopes: str, claims: Optional[str] = None, tenant_id: Optional[str] = None, **kwargs: Any + self, + *scopes: str, + claims: Optional[str] = None, + tenant_id: Optional[str] = None, + **kwargs: Any, ) -> AccessToken: if self._settings.get("client_id") or self._settings.get("identity_config"): raise ClientAuthenticationError(message=SERVICE_FABRIC_ERROR_MESSAGE) @@ -56,5 +79,7 @@ def _get_client_args(**kwargs: Any) -> Optional[Dict]: def _get_request(url: str, scope: str, identity_config: Dict) -> HttpRequest: return HttpRequest( - "GET", url, params=dict({"api-version": "2019-07-01-preview", "resource": scope}, **identity_config) + "GET", + url, + params=dict({"api-version": "2019-07-01-preview", "resource": scope}, **identity_config), ) diff --git a/sdk/identity/azure-identity/azure/identity/_internal/msal_managed_identity_client.py b/sdk/identity/azure-identity/azure/identity/_internal/msal_managed_identity_client.py index b17091c4141f..10f93ecd53c5 100644 --- a/sdk/identity/azure-identity/azure/identity/_internal/msal_managed_identity_client.py +++ b/sdk/identity/azure-identity/azure/identity/_internal/msal_managed_identity_client.py @@ -27,7 +27,7 @@ def __init__( self, *, client_id: Optional[str] = None, identity_config: Optional[Mapping[str, str]] = None, **kwargs: Any ) -> None: self._settings = {"client_id": client_id, "identity_config": identity_config or {}} - self._client = MsalClient(**kwargs) + self._client = self._create_http_client(**kwargs) managed_identity = self.get_managed_identity() self._msal_client = msal.ManagedIdentityClient(managed_identity, http_client=self._client) @@ -45,6 +45,9 @@ def get_unavailable_message(self, desc: str = "") -> str: def close(self) -> None: self.__exit__() + def _create_http_client(self, **kwargs: Any) -> Any: + return MsalClient(**kwargs) + def _request_token(self, *scopes: str, **kwargs: Any) -> AccessTokenInfo: if not scopes: raise ValueError('"get_token" requires at least one scope') diff --git a/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_arc.py b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_arc.py index c452061ef2a1..67730ad9b4db 100644 --- a/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_arc.py +++ b/sdk/identity/azure-identity/azure/identity/aio/_credentials/azure_arc.py @@ -11,17 +11,23 @@ from .._internal.managed_identity_base import AsyncManagedIdentityBase from .._internal.managed_identity_client import AsyncManagedIdentityClient from ..._constants import EnvironmentVariables -from ..._credentials.azure_arc import _get_request, _get_secret_key +from ..._credentials.azure_arc import _get_request, _get_secret_key, _validate_user_assigned_identity class AzureArcCredential(AsyncManagedIdentityBase): def get_client(self, **kwargs: Any) -> Optional[AsyncManagedIdentityClient]: url = os.environ.get(EnvironmentVariables.IDENTITY_ENDPOINT) imds = os.environ.get(EnvironmentVariables.IMDS_ENDPOINT) + identity_config = dict(kwargs.pop("identity_config", None) or {}) + client_id = kwargs.pop("client_id", None) + if client_id: + identity_config["client_id"] = client_id if url and imds: return AsyncManagedIdentityClient( per_retry_policies=[ArcChallengeAuthPolicy()], request_factory=functools.partial(_get_request, url), + identity_config=identity_config, + _content_callback=functools.partial(_validate_user_assigned_identity, identity_config), **kwargs, ) return None diff --git a/sdk/identity/azure-identity/pyproject.toml b/sdk/identity/azure-identity/pyproject.toml index 59c77c680fba..67057eb96d38 100644 --- a/sdk/identity/azure-identity/pyproject.toml +++ b/sdk/identity/azure-identity/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ dependencies = [ "azure-core>=1.31.0", "cryptography>=2.5", - "msal>=1.35.1,<1.38.0", + "msal>=1.38.0", "msal-extensions>=1.2.0", "typing-extensions>=4.0.0", ] diff --git a/sdk/identity/azure-identity/tests/test_managed_identity.py b/sdk/identity/azure-identity/tests/test_managed_identity.py index beda28ec5de4..2a635fccf0e2 100644 --- a/sdk/identity/azure-identity/tests/test_managed_identity.py +++ b/sdk/identity/azure-identity/tests/test_managed_identity.py @@ -3,6 +3,7 @@ # Licensed under the MIT License. # ------------------------------------ from itertools import product +import json import time import logging from unittest import mock @@ -15,19 +16,33 @@ from azure.identity._internal.user_agent import USER_AGENT from azure.identity._internal import within_credential_chain import pytest - -from helpers import build_aad_response, validating_transport, mock_response, Request, GET_TOKEN_METHODS +import requests + +from helpers import ( + build_aad_response, + validating_transport, + mock_response, + Request, + GET_TOKEN_METHODS, +) MANAGED_IDENTITY_ENVIRON = "azure.identity._credentials.managed_identity.os.environ" +SERVICE_FABRIC_ENVIRON = { + EnvironmentVariables.IDENTITY_ENDPOINT: "https://localhost/token", + EnvironmentVariables.IDENTITY_HEADER: "...", + EnvironmentVariables.IDENTITY_SERVER_THUMBPRINT: "0123456789abcdef0123456789abcdef01234567", +} ALL_ENVIRONMENTS = ( - {EnvironmentVariables.IDENTITY_ENDPOINT: "...", EnvironmentVariables.IDENTITY_HEADER: "..."}, # App Service - {EnvironmentVariables.MSI_ENDPOINT: "..."}, # Cloud Shell - { # Service Fabric + { EnvironmentVariables.IDENTITY_ENDPOINT: "...", EnvironmentVariables.IDENTITY_HEADER: "...", - EnvironmentVariables.IDENTITY_SERVER_THUMBPRINT: "...", - }, - {EnvironmentVariables.IDENTITY_ENDPOINT: "...", EnvironmentVariables.IMDS_ENDPOINT: "..."}, # Arc + }, # App Service + {EnvironmentVariables.MSI_ENDPOINT: "..."}, # Cloud Shell + SERVICE_FABRIC_ENVIRON, + { + EnvironmentVariables.IDENTITY_ENDPOINT: "...", + EnvironmentVariables.IMDS_ENDPOINT: "...", + }, # Arc { # token exchange EnvironmentVariables.AZURE_AUTHORITY_HOST: "https://localhost", EnvironmentVariables.AZURE_CLIENT_ID: "...", @@ -35,23 +50,35 @@ EnvironmentVariables.AZURE_FEDERATED_TOKEN_FILE: __file__, }, {}, # IMDS - {EnvironmentVariables.MSI_ENDPOINT: "...", EnvironmentVariables.MSI_SECRET: "..."}, # Azure ML + { + EnvironmentVariables.MSI_ENDPOINT: "...", + EnvironmentVariables.MSI_SECRET: "...", + }, # Azure ML +) +# Workaround while Service Fabric requires requests.Session for MSAL >= 1.38.0 +AZURE_CORE_TRANSPORT_ENVIRONMENTS = tuple( + environ for environ in ALL_ENVIRONMENTS if environ is not SERVICE_FABRIC_ENVIRON ) # Environments where MSAL-based managed identity clients are used MSAL_MANAGED_IDENTITY_ENVIRON = ( - {EnvironmentVariables.IDENTITY_ENDPOINT: "...", EnvironmentVariables.IDENTITY_HEADER: "..."}, # App Service - { # Service Fabric + { EnvironmentVariables.IDENTITY_ENDPOINT: "...", EnvironmentVariables.IDENTITY_HEADER: "...", - EnvironmentVariables.IDENTITY_SERVER_THUMBPRINT: "...", - }, - {EnvironmentVariables.IDENTITY_ENDPOINT: "...", EnvironmentVariables.IMDS_ENDPOINT: "..."}, # Arc - {EnvironmentVariables.MSI_ENDPOINT: "...", EnvironmentVariables.MSI_SECRET: "..."}, # Azure ML + }, # App Service + SERVICE_FABRIC_ENVIRON, + { + EnvironmentVariables.IDENTITY_ENDPOINT: "...", + EnvironmentVariables.IMDS_ENDPOINT: "...", + }, # Arc + { + EnvironmentVariables.MSI_ENDPOINT: "...", + EnvironmentVariables.MSI_SECRET: "...", + }, # Azure ML {}, # IMDS ) -@pytest.mark.parametrize("environ", ALL_ENVIRONMENTS) +@pytest.mark.parametrize("environ", AZURE_CORE_TRANSPORT_ENVIRONMENTS) def test_close(environ): transport = mock.MagicMock() with mock.patch.dict("os.environ", environ, clear=True): @@ -62,7 +89,7 @@ def test_close(environ): assert transport.__exit__.call_count == 1 -@pytest.mark.parametrize("environ", ALL_ENVIRONMENTS) +@pytest.mark.parametrize("environ", AZURE_CORE_TRANSPORT_ENVIRONMENTS) def test_context_manager(environ): transport = mock.MagicMock() with mock.patch.dict("os.environ", environ, clear=True): @@ -76,6 +103,48 @@ def test_context_manager(environ): assert transport.__exit__.call_count == 1 +def test_service_fabric_close(): + session = requests.Session() + with ( + mock.patch.dict("os.environ", SERVICE_FABRIC_ENVIRON, clear=True), + mock.patch( + "azure.identity._credentials.service_fabric.requests.Session", + return_value=session, + ), + mock.patch.object(session, "close") as close, + ): + credential = ManagedIdentityCredential() + credential.close() + + close.assert_called_once_with() + + +def test_service_fabric_context_manager(): + session = requests.Session() + with ( + mock.patch.dict("os.environ", SERVICE_FABRIC_ENVIRON, clear=True), + mock.patch( + "azure.identity._credentials.service_fabric.requests.Session", + return_value=session, + ), + mock.patch.object(session, "close") as close, + ): + with ManagedIdentityCredential(): + close.assert_not_called() + + close.assert_called_once_with() + + +@pytest.mark.parametrize( + "option", + ["transport", "raw_request_hook", "raw_response_hook", "retry_policy", "proxy_policy"], +) +def test_service_fabric_warns_when_pipeline_option_is_ignored(option): + with mock.patch.dict("os.environ", SERVICE_FABRIC_ENVIRON, clear=True): + with pytest.warns(UserWarning, match=option): + ManagedIdentityCredential(**{option: mock.Mock()}) + + def test_close_incomplete_configuration(): ManagedIdentityCredential().close() @@ -85,7 +154,10 @@ def test_context_manager_incomplete_configuration(): pass -@pytest.mark.parametrize("environ,get_token_method", product(ALL_ENVIRONMENTS, GET_TOKEN_METHODS)) +@pytest.mark.parametrize( + "environ,get_token_method", + product(AZURE_CORE_TRANSPORT_ENVIRONMENTS, GET_TOKEN_METHODS), +) def test_custom_hooks(environ, get_token_method): """The credential's pipeline should include azure-core's CustomHookPolicy""" @@ -109,7 +181,9 @@ def test_custom_hooks(environ, get_token_method): with mock.patch.dict(MANAGED_IDENTITY_ENVIRON, environ, clear=True): credential = ManagedIdentityCredential( - transport=transport, raw_request_hook=request_hook, raw_response_hook=response_hook + transport=transport, + raw_request_hook=request_hook, + raw_response_hook=response_hook, ) getattr(credential, get_token_method)(scope) @@ -120,7 +194,10 @@ def test_custom_hooks(environ, get_token_method): assert pipeline_response.http_response == expected_response -@pytest.mark.parametrize("environ,get_token_method", product(ALL_ENVIRONMENTS, GET_TOKEN_METHODS)) +@pytest.mark.parametrize( + "environ,get_token_method", + product(AZURE_CORE_TRANSPORT_ENVIRONMENTS, GET_TOKEN_METHODS), +) def test_tenant_id(environ, get_token_method): scope = "scope" expected_token = "***" @@ -142,7 +219,9 @@ def test_tenant_id(environ, get_token_method): with mock.patch.dict(MANAGED_IDENTITY_ENVIRON, environ, clear=True): credential = ManagedIdentityCredential( - transport=transport, raw_request_hook=request_hook, raw_response_hook=response_hook + transport=transport, + raw_request_hook=request_hook, + raw_response_hook=response_hook, ) getattr(credential, get_token_method)(scope) @@ -253,7 +332,11 @@ def test_azure_ml(get_token_method): url, method="GET", required_headers={"secret": secret, "User-Agent": USER_AGENT}, - required_params={"api-version": "2017-09-01", "resource": scope, "clientid": client_id}, + required_params={ + "api-version": "2017-09-01", + "resource": scope, + "clientid": client_id, + }, ), ], responses=[ @@ -272,14 +355,20 @@ def test_azure_ml(get_token_method): with mock.patch.dict( MANAGED_IDENTITY_ENVIRON, - {EnvironmentVariables.MSI_ENDPOINT: url, EnvironmentVariables.MSI_SECRET: secret}, + { + EnvironmentVariables.MSI_ENDPOINT: url, + EnvironmentVariables.MSI_SECRET: secret, + }, clear=True, ): token = getattr(ManagedIdentityCredential(transport=transport), get_token_method)(scope) assert token.token == expected_token assert abs(token.expires_on - expires_on) <= 1 - token = getattr(ManagedIdentityCredential(transport=transport, client_id=client_id), get_token_method)(scope) + token = getattr( + ManagedIdentityCredential(transport=transport, client_id=client_id), + get_token_method, + )(scope) assert token.token == expected_token assert abs(token.expires_on - expires_on) <= 1 @@ -305,7 +394,11 @@ def test_azure_ml_tenant_id(get_token_method): url, method="GET", required_headers={"secret": secret, "User-Agent": USER_AGENT}, - required_params={"api-version": "2017-09-01", "resource": scope, "clientid": client_id}, + required_params={ + "api-version": "2017-09-01", + "resource": scope, + "clientid": client_id, + }, ), ], responses=[ @@ -324,7 +417,10 @@ def test_azure_ml_tenant_id(get_token_method): with mock.patch.dict( MANAGED_IDENTITY_ENVIRON, - {EnvironmentVariables.MSI_ENDPOINT: url, EnvironmentVariables.MSI_SECRET: secret}, + { + EnvironmentVariables.MSI_ENDPOINT: url, + EnvironmentVariables.MSI_SECRET: secret, + }, clear=True, ): kwargs = {"tenant_id": "tenant_id"} @@ -375,7 +471,11 @@ def test_cloud_shell_identity_config(get_token_method): * 2, ) - with mock.patch.dict(MANAGED_IDENTITY_ENVIRON, {EnvironmentVariables.MSI_ENDPOINT: endpoint}, clear=True): + with mock.patch.dict( + MANAGED_IDENTITY_ENVIRON, + {EnvironmentVariables.MSI_ENDPOINT: endpoint}, + clear=True, + ): token = getattr(ManagedIdentityCredential(transport=transport), get_token_method)(scope) assert token.token == expected_token assert abs(token.expires_on - expires_on) <= 1 @@ -400,7 +500,10 @@ def test_prefers_app_service_2019_08_01(get_token_method): Request( base_url=endpoint, method="GET", - required_headers={"X-IDENTITY-HEADER": secret, "User-Agent": USER_AGENT}, + required_headers={ + "X-IDENTITY-HEADER": secret, + "User-Agent": USER_AGENT, + }, required_params={"api-version": "2019-08-01", "resource": scope}, ) ], @@ -543,13 +646,23 @@ def test_app_service_user_assigned_identity(get_token_method): Request( base_url=endpoint, method="GET", - required_headers={"X-IDENTITY-HEADER": secret, "User-Agent": USER_AGENT}, - required_params={"api-version": "2019-08-01", "client_id": client_id, "resource": scope}, + required_headers={ + "X-IDENTITY-HEADER": secret, + "User-Agent": USER_AGENT, + }, + required_params={ + "api-version": "2019-08-01", + "client_id": client_id, + "resource": scope, + }, ), Request( base_url=endpoint, method="GET", - required_headers={"X-IDENTITY-HEADER": secret, "User-Agent": USER_AGENT}, + required_headers={ + "X-IDENTITY-HEADER": secret, + "User-Agent": USER_AGENT, + }, required_params={ "api-version": "2019-08-01", "client_id": client_id, @@ -572,10 +685,16 @@ def test_app_service_user_assigned_identity(get_token_method): with mock.patch.dict( MANAGED_IDENTITY_ENVIRON, - {EnvironmentVariables.IDENTITY_ENDPOINT: endpoint, EnvironmentVariables.IDENTITY_HEADER: secret}, + { + EnvironmentVariables.IDENTITY_ENDPOINT: endpoint, + EnvironmentVariables.IDENTITY_HEADER: secret, + }, clear=True, ): - token = getattr(ManagedIdentityCredential(client_id=client_id, transport=transport), get_token_method)(scope) + token = getattr( + ManagedIdentityCredential(client_id=client_id, transport=transport), + get_token_method, + )(scope) assert token.token == expected_token assert abs(token.expires_on - expires_on) <= 1 @@ -777,7 +896,9 @@ def send(request, **kwargs): # Cloud Shell with mock.patch.dict( - MANAGED_IDENTITY_ENVIRON, {EnvironmentVariables.MSI_ENDPOINT: "https://localhost"}, clear=True + MANAGED_IDENTITY_ENVIRON, + {EnvironmentVariables.MSI_ENDPOINT: "https://localhost"}, + clear=True, ): credential = ManagedIdentityCredential(client_id=None, transport=mock.Mock(send=send)) token = getattr(credential, get_token_method)(scope) @@ -798,7 +919,11 @@ def test_imds_user_assigned_identity(get_token_method): base_url=endpoint, method="GET", required_headers={"Metadata": "true", "User-Agent": USER_AGENT}, - required_params={"api-version": "2018-02-01", "client_id": client_id, "resource": scope}, + required_params={ + "api-version": "2018-02-01", + "client_id": client_id, + "resource": scope, + }, ), ], responses=[ @@ -819,38 +944,41 @@ def test_imds_user_assigned_identity(get_token_method): # ensure e.g. $MSI_ENDPOINT isn't set, so we get ImdsCredential with mock.patch.dict("os.environ", clear=True): - token = getattr(ManagedIdentityCredential(client_id=client_id, transport=transport), get_token_method)(scope) + token = getattr( + ManagedIdentityCredential(client_id=client_id, transport=transport), + get_token_method, + )(scope) assert token.token == expected_token +@pytest.fixture +def mock_service_fabric_request(): + with mock.patch.object(requests.Session, "get") as session_get: + yield session_get + + @pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS) -def test_service_fabric(get_token_method): +def test_service_fabric(get_token_method, mock_service_fabric_request): """Service Fabric 2019-07-01-preview""" access_token = "****" expires_on = 42 - endpoint = "http://localhost:42/token" + endpoint = "https://localhost:42/token" secret = "expected-secret" - thumbprint = "SHA1HEX" + thumbprint = "0123456789abcdef0123456789abcdef01234567" scope = "scope" - def send(request, **kwargs): - # ensure the `claims` and `tenant_id` keywords from credential's `get_token` method don't make it to transport - assert "claims" not in kwargs - assert "tenant_id" not in kwargs - assert request.url.startswith(endpoint) - assert request.method == "GET" - assert request.headers["Secret"] == secret - assert request.query["api-version"] == "2019-07-01-preview" - assert request.query["resource"] == scope - - return mock_response( - json_payload={ + mock_service_fabric_request.return_value = mock.Mock( + status_code=200, + headers={"content-type": "application/json"}, + text=json.dumps( + { "access_token": access_token, "expires_on": str(expires_on), "resource": scope, "token_type": "Bearer", } - ) + ), + ) with mock.patch( "os.environ", @@ -860,38 +988,38 @@ def send(request, **kwargs): EnvironmentVariables.IDENTITY_SERVER_THUMBPRINT: thumbprint, }, ): - token = getattr(ManagedIdentityCredential(transport=mock.Mock(send=send)), get_token_method)(scope) + token = getattr(ManagedIdentityCredential(), get_token_method)(scope) assert token.token == access_token assert abs(token.expires_on - expires_on) <= 1 + mock_service_fabric_request.assert_called_once_with( + endpoint, + params={"api-version": "2019-07-01-preview", "resource": scope}, + headers={"Secret": secret}, + ) + @pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS) -def test_service_fabric_tenant_id(get_token_method): +def test_service_fabric_tenant_id(get_token_method, mock_service_fabric_request): access_token = "****" expires_on = 42 - endpoint = "http://localhost:42/token" + endpoint = "https://localhost:42/token" secret = "expected-secret" - thumbprint = "SHA1HEX" + thumbprint = "0123456789abcdef0123456789abcdef01234567" scope = "scope" - def send(request, **kwargs): - # ensure the `claims` and `tenant_id` keywords from credential's `get_token` method don't make it to transport - assert "claims" not in kwargs - assert "tenant_id" not in kwargs - assert request.url.startswith(endpoint) - assert request.method == "GET" - assert request.headers["Secret"] == secret - assert request.query["api-version"] == "2019-07-01-preview" - assert request.query["resource"] == scope - - return mock_response( - json_payload={ + mock_service_fabric_request.return_value = mock.Mock( + status_code=200, + headers={"content-type": "application/json"}, + text=json.dumps( + { "access_token": access_token, "expires_on": str(expires_on), "resource": scope, "token_type": "Bearer", } - ) + ), + ) with mock.patch( "os.environ", @@ -904,15 +1032,21 @@ def send(request, **kwargs): kwargs = {"tenant_id": "tenant_id"} if get_token_method == "get_token_info": kwargs = {"options": kwargs} - token = getattr(ManagedIdentityCredential(transport=mock.Mock(send=send)), get_token_method)(scope, **kwargs) + token = getattr(ManagedIdentityCredential(), get_token_method)(scope, **kwargs) assert token.token == access_token assert abs(token.expires_on - expires_on) <= 1 + mock_service_fabric_request.assert_called_once_with( + endpoint, + params={"api-version": "2019-07-01-preview", "resource": scope}, + headers={"Secret": secret}, + ) + @pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS) def test_service_fabric_with_client_id_error(get_token_method): """ManagedIdentityCredential should raise an error if a user identity is provided.""" - endpoint = "http://localhost:42" + endpoint = "https://localhost:42" with mock.patch( "os.environ", { @@ -1131,7 +1265,9 @@ def test_validate_identity_config_output(): def test_validate_cloud_shell_credential(): with mock.patch.dict( - MANAGED_IDENTITY_ENVIRON, {EnvironmentVariables.MSI_ENDPOINT: "https://localhost"}, clear=True + MANAGED_IDENTITY_ENVIRON, + {EnvironmentVariables.MSI_ENDPOINT: "https://localhost"}, + clear=True, ): ManagedIdentityCredential() with pytest.raises(ValueError): @@ -1182,7 +1318,10 @@ def test_log(caplog): assert "workload identity with client_id: foo" in caplog.text -@pytest.mark.parametrize("environ,get_token_method", product(MSAL_MANAGED_IDENTITY_ENVIRON, GET_TOKEN_METHODS)) +@pytest.mark.parametrize( + "environ,get_token_method", + product(MSAL_MANAGED_IDENTITY_ENVIRON, GET_TOKEN_METHODS), +) def test_claims_propagated(environ, get_token_method): """Test that claims passed are forwarded to MSAL's acquire_token_for_client.""" from azure.identity import ManagedIdentityCredential diff --git a/sdk/identity/azure-identity/tests/test_managed_identity_async.py b/sdk/identity/azure-identity/tests/test_managed_identity_async.py index 838a8914c6e4..8f36c2a65960 100644 --- a/sdk/identity/azure-identity/tests/test_managed_identity_async.py +++ b/sdk/identity/azure-identity/tests/test_managed_identity_async.py @@ -1073,19 +1073,90 @@ async def test_azure_arc_tenant_id(tmpdir, get_token_method): @pytest.mark.asyncio @pytest.mark.parametrize("get_token_method", GET_TOKEN_METHODS) -async def test_azure_arc_client_id(get_token_method): - """Azure Arc doesn't support user-assigned managed identity""" +@pytest.mark.parametrize( + "identity_type,request_parameter,response_parameter", + [ + ("client_id", "client_id", "client_id"), + ("object_id", "object_id", "object_id"), + ("resource_id", "msi_res_id", "msi_res_id"), + ("resource_id", "msi_res_id", "mi_res_id"), + ], +) +@pytest.mark.parametrize("response_identity", ["matching", "missing", "mismatched"]) +async def test_azure_arc_user_assigned_identity( + tmp_path, get_token_method, identity_type, request_parameter, response_parameter, response_identity +): + access_token = "****" + api_version = "2020-06-01" + expires_on = 42 + identity_endpoint = "http://localhost:42/token" + imds_endpoint = "http://localhost:42" + scope = "scope" + secret_key = "XXXX" + requested_identity = "some-identity" + + key_file = tmp_path / "key_file.key" + key_file.write_text(secret_key) + + required_params = { + "api-version": api_version, + "resource": scope, + request_parameter: requested_identity, + } + response_payload = { + "access_token": access_token, + "expires_on": expires_on, + "resource": scope, + "token_type": "Bearer", + } + if response_identity != "missing": + response_payload[response_parameter] = ( + requested_identity.upper() if response_identity == "matching" else "another-identity" + ) + + transport = async_validating_transport( + requests=[ + Request( + base_url=identity_endpoint, + method="GET", + required_headers={"Metadata": "true"}, + required_params=required_params, + ), + Request( + base_url=identity_endpoint, + method="GET", + required_headers={"Metadata": "true", "Authorization": "Basic {}".format(secret_key)}, + required_params=required_params, + ), + ], + responses=[ + mock_response(status_code=401, headers={"WWW-Authenticate": "Basic realm={}".format(key_file)}), + mock_response(json_payload=response_payload), + ], + ) + with mock.patch( "os.environ", { - EnvironmentVariables.IDENTITY_ENDPOINT: "http://localhost:42/token", - EnvironmentVariables.IMDS_ENDPOINT: "http://localhost:42", + EnvironmentVariables.IDENTITY_ENDPOINT: identity_endpoint, + EnvironmentVariables.IMDS_ENDPOINT: imds_endpoint, }, ): - credential = ManagedIdentityCredential(client_id="some-guid") - - with pytest.raises(ClientAuthenticationError): - await getattr(credential, get_token_method)("scope") + with mock.patch("azure.identity._credentials.azure_arc._validate_key_file", lambda x: None): + if identity_type == "client_id": + credential = ManagedIdentityCredential(transport=transport, client_id=requested_identity) + else: + credential = ManagedIdentityCredential( + transport=transport, identity_config={identity_type: requested_identity} + ) + + if response_identity == "matching": + token = await getattr(credential, get_token_method)(scope) + assert token.token == access_token + assert token.expires_on == expires_on + else: + with pytest.raises(ClientAuthenticationError, match="did not confirm"): + await getattr(credential, get_token_method)(scope) @pytest.mark.asyncio