diff --git a/changelog.md b/changelog.md index 2c6e3c50..bdd5df0a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,8 @@ # Microsoft 365 Agents SDK for Python - Release Notes v1.4.0 (Unreleased) +## Major Features & Enhancements +- Added support for Workload Identity + ## New Models & APIs - **Regionalized UserTokenClient Support**: Added optional argument to `CloudAdapter` to configure Token Service endpoint used by `RestChannelServiceClientFactory` when creating `UserTokenClient` instances. diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 2a346ea6..81619e58 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -281,6 +281,19 @@ def get_assertion() -> str: ) return result["access_token"] + client_credential = {"client_assertion": get_assertion} + elif self._msal_configuration.AUTH_TYPE == AuthTypes.workload_identity: + if not self._msal_configuration.FEDERATED_TOKEN_FILE: + raise ValueError( + "FEDERATED_TOKEN_FILE must be set in configuration." + ) + + federated_token_file = self._msal_configuration.FEDERATED_TOKEN_FILE + + def get_assertion() -> str: + with open(federated_token_file, encoding="utf-8") as f: + return f.read().strip() + client_credential = {"client_assertion": get_assertion} else: logger.error( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py index ff0cd0ae..c9edd1d5 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py @@ -27,6 +27,7 @@ "CERTPFXFILE", "CONNECTIONNAME", "FEDERATEDCLIENTID", + "FEDERATEDTOKENFILE", "SCOPES", "AZUREREGION", "REGIONALAUTHORITY", @@ -81,6 +82,8 @@ class AgentAuthConfiguration: enforced by JwtTokenValidator (per issue #626) whenever the verified token's issuer is a recognized Entra issuer with a GUID tenant, regardless of this flag. + ANONYMOUS_ALLOWED: Whether anonymous access is allowed (default False). + FEDERATED_TOKEN_FILE: The path to the federated token file (if using federated credentials authentication). """ TENANT_ID: str | None @@ -97,6 +100,7 @@ class AgentAuthConfiguration: IDPM_RESOURCE: str | None ANONYMOUS_ALLOWED: bool = False VALIDATE_ISSUER: bool = False + FEDERATED_TOKEN_FILE: str | None # Provider-specific settings that aren't first-class fields (e.g. the Entra # sidecar's SERVICE_NAME, SIDECAR_BASE_URL). Preserved here as a single dict @@ -129,6 +133,7 @@ def __init__( anonymous_allowed: bool | None = None, issuers: list[str] | None = None, validate_issuer: bool | None = None, + federated_token_file: str | None = None, **kwargs: Any, ): @@ -148,6 +153,9 @@ def __init__( self.FEDERATED_CLIENT_ID = federated_client_id or kwargs.get( "FEDERATEDCLIENTID", None ) + self.FEDERATED_TOKEN_FILE = federated_token_file or kwargs.get( + "FEDERATEDTOKENFILE", None + ) self.SCOPES = scopes or kwargs.get("SCOPES", None) # Azure regional token service. Falls back to the legacy "REGIONALAUTHORITY" # configuration key when "AZUREREGION" is not provided. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py index d61ec469..952ee898 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/auth_types.py @@ -13,3 +13,4 @@ class AuthTypes(str, Enum): federated_credentials = "FederatedCredentials" identity_proxy_manager = "IdentityProxyManager" entra_auth_sidecar = "EntraAuthSideCar" + workload_identity = "WorkloadIdentity" diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index adb5c140..df51242e 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -260,6 +260,43 @@ def test_create_client_application_azure_region_defaults_none(self, mocker): assert mock_cca.call_args.kwargs["azure_region"] is None +class TestMsalAuthWorkloadIdentity: + def test_create_client_application_reads_projected_token(self, mocker, tmp_path): + token_file = tmp_path / "workload-token" + token_file.write_text(" first-token\r\n", encoding="utf-8") + config = AgentAuthConfiguration( + auth_type=AuthTypes.workload_identity, + tenant_id="12345678-1234-1234-1234-123456789abc", + client_id="test-client-id", + federated_token_file=str(token_file), + ) + mock_cca = mocker.patch( + "microsoft_agents.authentication.msal.msal_auth.ConfidentialClientApplication" + ) + + MsalAuth(config)._create_client_application() + + client_assertion = mock_cca.call_args.kwargs["client_credential"][ + "client_assertion" + ] + assert client_assertion() == "first-token" + + token_file.write_text("\trefreshed-token\n", encoding="utf-8") + assert client_assertion() == "refreshed-token" + + def test_create_client_application_requires_token_file(self): + config = AgentAuthConfiguration( + auth_type=AuthTypes.workload_identity, + tenant_id="12345678-1234-1234-1234-123456789abc", + client_id="test-client-id", + ) + + with pytest.raises( + ValueError, match="FEDERATED_TOKEN_FILE must be set in configuration" + ): + MsalAuth(config)._create_client_application() + + class TestMsalAuthIdentityProxyManager: """ Test suite for the Identity Proxy Manager (IDPM) authentication type. diff --git a/tests/hosting_core/test_auth_configuration.py b/tests/hosting_core/test_auth_configuration.py index 2ef8a4c7..03d27e5e 100644 --- a/tests/hosting_core/test_auth_configuration.py +++ b/tests/hosting_core/test_auth_configuration.py @@ -102,6 +102,21 @@ def test_empty_settings(self): assert auth_config.SCOPES is None assert auth_config.AZURE_REGION is None + def test_workload_identity_token_file_from_kwargs(self): + auth_config = AgentAuthConfiguration( + AUTHTYPE="WorkloadIdentity", + CLIENTID="test-client-id", + TENANTID="test-tenant-id", + FEDERATEDTOKENFILE="/var/run/secrets/azure/tokens/azure-identity-token", + ) + + assert auth_config.AUTH_TYPE == AuthTypes.workload_identity + assert ( + auth_config.FEDERATED_TOKEN_FILE + == "/var/run/secrets/azure/tokens/azure-identity-token" + ) + assert "FEDERATEDTOKENFILE" not in auth_config.provider_settings + def test_azure_region_from_parameter(self): auth_config = AgentAuthConfiguration(azure_region="westus") assert auth_config.AZURE_REGION == "westus"