diff --git a/changelog.md b/changelog.md index 120940e2..e20fdd34 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,14 @@ +# Microsoft 365 Agents SDK for Python - Release Notes v1.6.1 (Unreleased) + +**Release Date:** Unreleased +**Previous Version:** 1.5.0 (Released 2026-08-26) + +## New Models & APIs + +- **MSAL Token Credential**: Added `MsalTokenCredential`, an Azure Core-compatible asynchronous token credential backed by MSAL, for authenticating Azure SDK clients that accept an `AsyncTokenCredential`. + +--- + # Microsoft 365 Agents SDK for Python - Release Notes v1.5.0 **Release Date:** 2026-08-26 diff --git a/dev/integration/tests/auth/test_msal_token_credential.py b/dev/integration/tests/auth/test_msal_token_credential.py new file mode 100644 index 00000000..6ac49ce1 --- /dev/null +++ b/dev/integration/tests/auth/test_msal_token_credential.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import os +import time + +import jwt +import pytest +from azure.core.credentials import AccessToken +from dotenv import dotenv_values +from jwt import PyJWKClient + +from microsoft_agents.authentication.msal import MsalTokenCredential +from microsoft_agents.hosting.core import AgentAuthConfiguration + +from tests.utils.config import REAL_SERVICE_CONNECTION_ENV_VARS +from tests.utils.pytest import skip_if_no_var + +_CLIENT_ID_ENV_VAR = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID" +_CLIENT_SECRET_ENV_VAR = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET" +_TENANT_ID_ENV_VAR = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID" +_BOT_FRAMEWORK_RESOURCE = "https://api.botframework.com" +_BOT_FRAMEWORK_SCOPE = f"{_BOT_FRAMEWORK_RESOURCE}/.default" +_ENVIRONMENT = {**dotenv_values(".env"), **os.environ} + +pytestmark = skip_if_no_var( + *REAL_SERVICE_CONNECTION_ENV_VARS, + environ=_ENVIRONMENT, +) + + +@pytest.fixture +def auth_config() -> AgentAuthConfiguration: + return AgentAuthConfiguration( + client_id=_ENVIRONMENT[_CLIENT_ID_ENV_VAR], + client_secret=_ENVIRONMENT[_CLIENT_SECRET_ENV_VAR], + tenant_id=_ENVIRONMENT[_TENANT_ID_ENV_VAR], + ) + + +@pytest.mark.asyncio +async def test_msal_token_credential_acquires_valid_token( + auth_config: AgentAuthConfiguration, +): + credential = MsalTokenCredential(auth_config) + + token = await credential.get_token(_BOT_FRAMEWORK_SCOPE) + + assert isinstance(token, AccessToken) + assert token.token + assert token.expires_on > time.time() + + unverified_claims = jwt.decode( + token.token, + options={"verify_signature": False}, + ) + token_version = unverified_claims.get("ver") + assert token_version in ("1.0", "2.0") + + tenant_id = auth_config.TENANT_ID + issuer = ( + f"https://login.microsoftonline.com/{tenant_id}/v2.0" + if token_version == "2.0" + else f"https://sts.windows.net/{tenant_id}/" + ) + jwks_client = PyJWKClient( + f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys" + ) + signing_key = await asyncio.to_thread( + jwks_client.get_signing_key_from_jwt, + token.token, + ) + + claims = jwt.decode( + token.token, + signing_key.key, + algorithms=["RS256"], + audience=_BOT_FRAMEWORK_RESOURCE, + issuer=issuer, + ) + + assert claims["tid"] == tenant_id + assert abs(claims["exp"] - token.expires_on) <= 5 + client_id_claim = "azp" if token_version == "2.0" else "appid" + assert claims[client_id_claim] == auth_config.CLIENT_ID diff --git a/dev/integration/tests/utils/pytest.py b/dev/integration/tests/utils/pytest.py index c419524c..fbc7ff3c 100644 --- a/dev/integration/tests/utils/pytest.py +++ b/dev/integration/tests/utils/pytest.py @@ -2,10 +2,16 @@ # Licensed under the MIT License. import os + import pytest from dotenv import dotenv_values -def skip_if_no_var(*env_vars: str, environ: dict | None = None, load_root_env_file: bool = False): + +def skip_if_no_var( + *env_vars: str, + environ: dict | None = None, + load_root_env_file: bool = False, +): """Skip the test if any of the specified environment variables are not set. :param env_vars: The environment variable names to check. @@ -15,7 +21,11 @@ def skip_if_no_var(*env_vars: str, environ: dict | None = None, load_root_env_fi if load_root_env_file: # Load environment variables from the root .env file if specified environ = {**os.environ, **dotenv_values(".env")} + environment = os.environ if environ is None else environ return pytest.mark.skipif( - any(env_var not in (environ or os.environ) for env_var in env_vars), - reason=f"Skipping test because one or more environment variables are not set: {', '.join(env_vars)}" + any(not environment.get(env_var) for env_var in env_vars), + reason=( + "Skipping test because one or more environment variables are not set: " + f"{', '.join(env_vars)}" + ), ) \ No newline at end of file diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py index 8536f337..f1414f49 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py @@ -1,7 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .msal_auth import MsalAuth from .msal_connection_manager import MsalConnectionManager +from .msal_token_credential import MsalTokenCredential __all__ = [ "MsalAuth", "MsalConnectionManager", + "MsalTokenCredential", ] 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 2fc9f1c4..74edf7c1 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 @@ -6,6 +6,7 @@ import re import asyncio import logging +import time import jwt from typing import Optional from urllib.parse import urlparse, ParseResult as URI @@ -16,6 +17,7 @@ SystemAssignedManagedIdentity, TokenCache, ) +from azure.core.credentials import AccessToken from requests import Session from microsoft_agents.activity._utils import _DeferredString @@ -73,6 +75,28 @@ def configuration(self) -> AgentAuthConfiguration: async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: + """Gets an access token for the specified resource URL and scopes. + + :param resource_url: The resource URL for which to acquire the access token. + :param scopes: The scopes for which the access token is requested. + :param force_refresh: Whether to force a refresh of the access token. + :return: The acquired access token as a string. + :rtype: str + """ + access_token = await self._get_access_token(resource_url, scopes, force_refresh) + return access_token.token + + async def _get_access_token( + self, resource_url: str, scopes: list[str], force_refresh: bool = False + ) -> AccessToken: + """Internal method to get an access token for the specified resource URL and scopes. + + :param resource_url: The resource URL for which to acquire the access token. + :param scopes: The scopes for which the access token is requested. + :param force_refresh: Whether to force a refresh of the access token. + :return: The acquired access token as an AccessToken object. + :rtype: AccessToken + """ with spans.GetAccessToken( scopes, self._msal_configuration.AUTH_TYPE, @@ -99,7 +123,7 @@ async def get_access_token( msal_auth_client, scopes=local_scopes ) else: - auth_result_payload = None + auth_result_payload = {} res = ( auth_result_payload.get("access_token") if auth_result_payload else None @@ -114,7 +138,15 @@ async def get_access_token( ) ) - return res + expires_on = auth_result_payload.get("expires_on") + if expires_on is not None: + return AccessToken(res, int(expires_on)) + + expires_in = auth_result_payload.get("expires_in") + if expires_in is None: + raise ValueError("Token response does not include an expiration.") + + return AccessToken(res, int(time.time()) + int(expires_in)) async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str @@ -186,6 +218,11 @@ def _resolve_authority( def _resolve_azure_region(config: AgentAuthConfiguration) -> str | None: """Resolves the Azure regional token service (ESTS-R) to use, if configured. + :param config: The agent authentication configuration. + :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + :return: The resolved Azure region or None if not configured. + :rtype: str | None + Returns the configured region only when it is populated and non-whitespace, otherwise None so that MSAL falls back to the global token service. """ diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py new file mode 100644 index 00000000..933eebd0 --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from azure.core.credentials import AccessToken +from azure.core.credentials_async import AsyncTokenCredential + +from microsoft_agents.hosting.core import AgentAuthConfiguration + +from .msal_auth import MsalAuth + +logger = logging.getLogger(__name__) + + +def _get_resource(scope: str) -> str: + """Extracts the resource by removing a trailing '/.default' from the scope. + + :param scope: The scope string. + :return: The extracted resource string. + :rtype: str + """ + return scope.removesuffix("/.default") + + +class MsalTokenCredential(AsyncTokenCredential): + """Provides an asynchronous Azure Core token credential using MSAL.""" + + def __init__(self, config: AgentAuthConfiguration): + """Initializes the MsalTokenCredential with the given configuration. + + :param config: The agent authentication configuration. + :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + """ + self._config = config + self._provider: MsalAuth | None = None + + async def get_token(self, *scopes: str, **kwargs) -> AccessToken: + """Acquire an access token for the specified scopes. + + :param scopes: The scopes for which the access token is requested. + :param kwargs: Additional keyword arguments. + + :return: The acquired access token. + :rtype: AccessToken + """ + + logger.debug("get_token scope=%s", scopes) + + if not scopes: + raise ValueError("At least one scope must be provided.") + + if not self._provider: + self._provider = MsalAuth(self._config) + + resource = _get_resource(scopes[0]) + + return await self._provider._get_access_token(resource, list(scopes)) diff --git a/libraries/microsoft-agents-authentication-msal/readme.md b/libraries/microsoft-agents-authentication-msal/readme.md index 0b182f86..fe51e9b5 100644 --- a/libraries/microsoft-agents-authentication-msal/readme.md +++ b/libraries/microsoft-agents-authentication-msal/readme.md @@ -217,6 +217,7 @@ class AuthTypes(str, Enum): - **`MsalAuth`** - Core authentication provider using MSAL - **`MsalConnectionManager`** - Manages multiple authentication connections +- **`MsalTokenCredential`** - Asynchronous Azure Core token credential backed by MSAL ## Features diff --git a/libraries/microsoft-agents-authentication-msal/setup.py b/libraries/microsoft-agents-authentication-msal/setup.py index cc90cd35..31186bc2 100644 --- a/libraries/microsoft-agents-authentication-msal/setup.py +++ b/libraries/microsoft-agents-authentication-msal/setup.py @@ -13,6 +13,7 @@ version=package_version, install_requires=[ f"microsoft-agents-hosting-core=={package_version}", + "azure-core", "msal>=1.34.0", "requests>=2.32.3", ], diff --git a/tests/_common/testing_objects/mocks/mock_msal_auth.py b/tests/_common/testing_objects/mocks/mock_msal_auth.py index f2988f1d..d90fa762 100644 --- a/tests/_common/testing_objects/mocks/mock_msal_auth.py +++ b/tests/_common/testing_objects/mocks/mock_msal_auth.py @@ -12,7 +12,10 @@ def __init__( self, mocker, client_type, - acquire_token_for_client_return={"access_token": "token"}, + acquire_token_for_client_return={ + "access_token": "token", + "expires_in": 3600, + }, ): super().__init__(AgentAuthConfiguration()) mock_client = mocker.Mock(spec=client_type) diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 7aa00094..9c789551 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -40,6 +40,29 @@ async def test_get_access_token_confidential(self, mocker): scopes=["test-scope"] ) + @pytest.mark.asyncio + async def test_get_access_token_converts_expires_in_to_expires_on(self, mocker): + mock_auth = MockMsalAuth( + mocker, + ConfidentialClientApplication, + { + "access_token": "token", + "expires_in": 3600, + }, + ) + mocker.patch( + "microsoft_agents.authentication.msal.msal_auth.time.time", + return_value=1000, + ) + + token = await mock_auth._get_access_token( + "https://test.api.botframework.com", + scopes=["test-scope"], + ) + + assert token.token == "token" + assert token.expires_on == 4600 + @pytest.mark.asyncio async def test_acquire_token_on_behalf_of_managed_identity(self, mocker): mock_auth = MockMsalAuth(mocker, ManagedIdentityClient) diff --git a/tests/authentication_msal/test_msal_token_credential.py b/tests/authentication_msal/test_msal_token_credential.py new file mode 100644 index 00000000..78888a8f --- /dev/null +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from azure.core.credentials import AccessToken + +from microsoft_agents.authentication.msal import MsalTokenCredential +from microsoft_agents.authentication.msal.msal_token_credential import _get_resource +from microsoft_agents.hosting.core import AgentAuthConfiguration + +_FIRST_SCOPE = "https://api.botframework.com/.default" +_SECOND_SCOPE = "https://graph.microsoft.com/.default" +_MSAL_AUTH_PATH = "microsoft_agents.authentication.msal.msal_token_credential.MsalAuth" + + +@pytest.fixture +def auth_config() -> AgentAuthConfiguration: + return AgentAuthConfiguration( + client_id="client-id", + client_secret="client-secret", + tenant_id="tenant-id", + ) + + +@pytest.mark.parametrize( + "scope, expected_resource", + [ + ("https://api.botframework.com/.default", "https://api.botframework.com"), + ( + "https://graph.microsoft.com/User.Read", + "https://graph.microsoft.com/User.Read", + ), + ("api://client-id/.default", "api://client-id"), + ("api://client-id/access_as_user", "api://client-id/access_as_user"), + ("api://client-id", "api://client-id"), + ("resource/scope/with/segments", "resource/scope/with/segments"), + ("resource/", "resource/"), + ("/scope", "/scope"), + ("scope-without-slash", "scope-without-slash"), + ("", ""), + ], +) +def test_get_resource(scope: str, expected_resource: str): + assert _get_resource(scope) == expected_resource + + +@pytest.mark.asyncio +async def test_get_token_lazily_creates_provider_and_forwards_scope( + mocker, + auth_config: AgentAuthConfiguration, +): + msal_auth_class = mocker.patch(_MSAL_AUTH_PATH) + msal_auth = msal_auth_class.return_value + expected_token = AccessToken("access-token", 1234567890) + msal_auth._get_access_token = mocker.AsyncMock(return_value=expected_token) + credential = MsalTokenCredential(auth_config) + msal_auth_class.assert_not_called() + + token = await credential.get_token(_FIRST_SCOPE) + + assert token is expected_token + assert credential._provider is msal_auth + msal_auth_class.assert_called_once_with(auth_config) + msal_auth._get_access_token.assert_awaited_once_with( + "https://api.botframework.com", + [_FIRST_SCOPE], + ) + + +@pytest.mark.asyncio +async def test_get_token_reuses_provider( + mocker, + auth_config: AgentAuthConfiguration, +): + msal_auth_class = mocker.patch(_MSAL_AUTH_PATH) + msal_auth = msal_auth_class.return_value + msal_auth._get_access_token = mocker.AsyncMock( + return_value=AccessToken("access-token", 1234567890) + ) + credential = MsalTokenCredential(auth_config) + + await credential.get_token(_FIRST_SCOPE) + await credential.get_token(_SECOND_SCOPE) + + msal_auth_class.assert_called_once_with(auth_config) + assert msal_auth._get_access_token.await_args_list == [ + mocker.call("https://api.botframework.com", [_FIRST_SCOPE]), + mocker.call("https://graph.microsoft.com", [_SECOND_SCOPE]), + ] + + +@pytest.mark.asyncio +async def test_get_token_requires_at_least_one_scope( + mocker, + auth_config: AgentAuthConfiguration, +): + msal_auth_class = mocker.patch(_MSAL_AUTH_PATH) + credential = MsalTokenCredential(auth_config) + + with pytest.raises(ValueError, match="At least one scope must be provided"): + await credential.get_token() + + msal_auth_class.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_token_propagates_msal_auth_error( + mocker, + auth_config: AgentAuthConfiguration, +): + msal_auth = mocker.patch(_MSAL_AUTH_PATH).return_value + msal_auth._get_access_token = mocker.AsyncMock( + side_effect=RuntimeError("token acquisition failed") + ) + credential = MsalTokenCredential(auth_config) + + with pytest.raises(RuntimeError, match="token acquisition failed"): + await credential.get_token(_FIRST_SCOPE)