From b008d7e805e535c5b26dc6fe7b574b80c78d9935 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 10:00:53 -0700 Subject: [PATCH 01/14] MsalTokenCredential and AsyncMsalTokenCredential definitions --- .../authentication/msal/msal_auth.py | 5 ++ .../msal/msal_token_credential.py | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py 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..14f47287 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 @@ -186,6 +186,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..8483d566 --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +import logging + +from azure.core.credentials import TokenCredential, AccessToken +from azure.core.credentials_async import AsyncTokenCredential + +from microsoft_agents.hosting.core import AgentAuthConfiguration + +from .msal_auth import MsalAuth + +logger = logging.getLogger(__name__) + + +class AsyncMsalTokenCredential(AsyncTokenCredential): + """AsyncMsalTokenCredential provides an asynchronous implementation for acquiring access tokens 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 + + 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.") + + provider = MsalAuth(self._config) + + token = await provider.get_access_token(scopes[0], list(scopes)) + return AccessToken(token, 0) + + +class MsalTokenCredential(TokenCredential): + """MsalTokenCredential provides a synchronous wrapper around AsyncMsalTokenCredential for acquiring access tokens.""" + + 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._async_credential = AsyncMsalTokenCredential(config) + + 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 + """ + return asyncio.run(self._async_credential.get_token(*scopes, **kwargs)) From 9a292480775aa7f12831c2d3284091f35ca1a47c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 10:14:51 -0700 Subject: [PATCH 02/14] Adding internal _get_access_token method --- .../tests/auth/test_msal_token_credential.py | 46 ++++++++++++ dev/integration/tests/utils/pytest.py | 16 ++++- .../authentication/msal/__init__.py | 5 ++ .../authentication/msal/msal_auth.py | 30 +++++++- .../msal/msal_token_credential.py | 35 ++-------- .../readme.md | 31 ++++++++ .../test_msal_token_credential.py | 70 +++++++++++++++++++ 7 files changed, 198 insertions(+), 35 deletions(-) create mode 100644 dev/integration/tests/auth/test_msal_token_credential.py create mode 100644 tests/authentication_msal/test_msal_token_credential.py 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..ce619b8c --- /dev/null +++ b/dev/integration/tests/auth/test_msal_token_credential.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +import pytest +from azure.core.credentials import AccessToken +from dotenv import dotenv_values + +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_SCOPE = "https://api.botframework.com/.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_token( + auth_config: AgentAuthConfiguration, +): + credential = MsalTokenCredential(auth_config) + + token = await credential.get_token(_BOT_FRAMEWORK_SCOPE) + + assert isinstance(token, AccessToken) + assert token.token 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 14f47287..6e3f251e 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 @@ -16,6 +16,7 @@ SystemAssignedManagedIdentity, TokenCache, ) +from azure.core.credentials import AccessToken from requests import Session from microsoft_agents.activity._utils import _DeferredString @@ -73,6 +74,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, @@ -104,6 +127,11 @@ async def get_access_token( res = ( auth_result_payload.get("access_token") if auth_result_payload else None ) + expires_in = ( + int(auth_result_payload.get("expires_in", 0)) + if auth_result_payload + else 0 + ) if not res: logger.error( "Failed to acquire token for resource %s", auth_result_payload @@ -114,7 +142,7 @@ async def get_access_token( ) ) - return res + return AccessToken(res, expires_in) async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str 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 index 8483d566..25cf82e9 100644 --- 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 @@ -1,10 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import asyncio import logging -from azure.core.credentials import TokenCredential, AccessToken +from azure.core.credentials import AccessToken from azure.core.credentials_async import AsyncTokenCredential from microsoft_agents.hosting.core import AgentAuthConfiguration @@ -14,8 +13,8 @@ logger = logging.getLogger(__name__) -class AsyncMsalTokenCredential(AsyncTokenCredential): - """AsyncMsalTokenCredential provides an asynchronous implementation for acquiring access tokens using MSAL.""" +class MsalTokenCredential(AsyncTokenCredential): + """Provides an asynchronous Azure Core token credential using MSAL.""" def __init__(self, config: AgentAuthConfiguration): """Initializes the MsalTokenCredential with the given configuration. @@ -41,30 +40,4 @@ async def get_token(self, *scopes: str, **kwargs) -> AccessToken: raise ValueError("At least one scope must be provided.") provider = MsalAuth(self._config) - - token = await provider.get_access_token(scopes[0], list(scopes)) - return AccessToken(token, 0) - - -class MsalTokenCredential(TokenCredential): - """MsalTokenCredential provides a synchronous wrapper around AsyncMsalTokenCredential for acquiring access tokens.""" - - 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._async_credential = AsyncMsalTokenCredential(config) - - 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 - """ - return asyncio.run(self._async_credential.get_token(*scopes, **kwargs)) + return await provider._get_access_token(scopes[0], list(scopes)) diff --git a/libraries/microsoft-agents-authentication-msal/readme.md b/libraries/microsoft-agents-authentication-msal/readme.md index 0b182f86..a3b2f6e9 100644 --- a/libraries/microsoft-agents-authentication-msal/readme.md +++ b/libraries/microsoft-agents-authentication-msal/readme.md @@ -217,6 +217,37 @@ class AuthTypes(str, Enum): - **`MsalAuth`** - Core authentication provider using MSAL - **`MsalConnectionManager`** - Manages multiple authentication connections +- **`MsalTokenCredential`** - Asynchronous Azure Core token credential backed by MSAL + +## Azure Core Token Credential + +`MsalTokenCredential` adapts an `AgentAuthConfiguration` to the Azure Core +`AsyncTokenCredential` interface. Use it with asynchronous Azure SDK clients or +other libraries that accept an `AsyncTokenCredential`. + +Create the authentication configuration with the client ID, tenant ID, and +client secret for your application: + +```python +import os + +from microsoft_agents.authentication.msal import MsalTokenCredential +from microsoft_agents.hosting.core import AgentAuthConfiguration + +auth_config = AgentAuthConfiguration( + client_id=os.environ["CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID"], + client_secret=os.environ[ + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET" + ], + tenant_id=os.environ["CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID"], +) + +credential = MsalTokenCredential(auth_config) +token = await credential.get_token("https://api.botframework.com/.default") +``` + +At least one scope is required. The first scope must be an absolute resource +URI, and all requested scopes are passed to MSAL during token acquisition. ## Features 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..f5884619 --- /dev/null +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -0,0 +1,70 @@ +# 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.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.asyncio +async def test_get_token_returns_access_token_and_forwards_scopes( + 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="access-token") + credential = MsalTokenCredential(auth_config) + + token = await credential.get_token(_FIRST_SCOPE, _SECOND_SCOPE) + + assert token == AccessToken("access-token", 0) + msal_auth_class.assert_called_once_with(auth_config) + msal_auth.get_access_token.assert_awaited_once_with( + _FIRST_SCOPE, + [_FIRST_SCOPE, _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) From 45708db7e543eb544b9fd8bd53efa9f3a83b3e8e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 10:25:48 -0700 Subject: [PATCH 03/14] Improved handling of resource from scope --- .../authentication/msal/msal_auth.py | 14 +++++------ .../msal/msal_token_credential.py | 18 ++++++++++++++- .../testing_objects/mocks/mock_msal_auth.py | 5 +++- tests/authentication_msal/test_msal_auth.py | 23 +++++++++++++++++++ .../test_msal_token_credential.py | 9 ++++---- 5 files changed, 56 insertions(+), 13 deletions(-) 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 6e3f251e..83afbd3a 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 @@ -122,16 +123,11 @@ 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 ) - expires_in = ( - int(auth_result_payload.get("expires_in", 0)) - if auth_result_payload - else 0 - ) if not res: logger.error( "Failed to acquire token for resource %s", auth_result_payload @@ -142,7 +138,11 @@ async def _get_access_token( ) ) - return AccessToken(res, expires_in) + 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 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 index 25cf82e9..8869d066 100644 --- 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 @@ -13,6 +13,20 @@ logger = logging.getLogger(__name__) +def _get_resource(scope: str) -> str: + """Extracts the resource from the given scope by removing the trailing '/.default' if present. + + :param scope: The scope string. + :return: The extracted resource string. + :rtype: str + """ + try: + i = scope.rindex("/") + return scope[:i] + except ValueError: + return scope + + class MsalTokenCredential(AsyncTokenCredential): """Provides an asynchronous Azure Core token credential using MSAL.""" @@ -39,5 +53,7 @@ async def get_token(self, *scopes: str, **kwargs) -> AccessToken: if not scopes: raise ValueError("At least one scope must be provided.") + resource = _get_resource(scopes[0]) + provider = MsalAuth(self._config) - return await provider._get_access_token(scopes[0], list(scopes)) + return await provider._get_access_token(resource, list(scopes)) 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 index f5884619..3c9a666e 100644 --- a/tests/authentication_msal/test_msal_token_credential.py +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -28,14 +28,15 @@ async def test_get_token_returns_access_token_and_forwards_scopes( ): msal_auth_class = mocker.patch(_MSAL_AUTH_PATH) msal_auth = msal_auth_class.return_value - msal_auth.get_access_token = mocker.AsyncMock(return_value="access-token") + expected_token = AccessToken("access-token", 1234567890) + msal_auth._get_access_token = mocker.AsyncMock(return_value=expected_token) credential = MsalTokenCredential(auth_config) token = await credential.get_token(_FIRST_SCOPE, _SECOND_SCOPE) - assert token == AccessToken("access-token", 0) + assert token is expected_token msal_auth_class.assert_called_once_with(auth_config) - msal_auth.get_access_token.assert_awaited_once_with( + msal_auth._get_access_token.assert_awaited_once_with( _FIRST_SCOPE, [_FIRST_SCOPE, _SECOND_SCOPE], ) @@ -61,7 +62,7 @@ async def test_get_token_propagates_msal_auth_error( auth_config: AgentAuthConfiguration, ): msal_auth = mocker.patch(_MSAL_AUTH_PATH).return_value - msal_auth.get_access_token = mocker.AsyncMock( + msal_auth._get_access_token = mocker.AsyncMock( side_effect=RuntimeError("token acquisition failed") ) credential = MsalTokenCredential(auth_config) From 7aa02dcd13120f5942a5678cef398b6f99788b30 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 10:34:10 -0700 Subject: [PATCH 04/14] Improvements --- .../tests/auth/test_msal_token_credential.py | 2 ++ .../msal/msal_token_credential.py | 2 +- .../test_msal_token_credential.py | 20 ++++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/dev/integration/tests/auth/test_msal_token_credential.py b/dev/integration/tests/auth/test_msal_token_credential.py index ce619b8c..e5a9e38f 100644 --- a/dev/integration/tests/auth/test_msal_token_credential.py +++ b/dev/integration/tests/auth/test_msal_token_credential.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import os +import time import pytest from azure.core.credentials import AccessToken @@ -44,3 +45,4 @@ async def test_msal_token_credential_acquires_token( assert isinstance(token, AccessToken) assert token.token + assert token.expires_on > time.time() 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 index 8869d066..05329fa9 100644 --- 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 @@ -14,7 +14,7 @@ def _get_resource(scope: str) -> str: - """Extracts the resource from the given scope by removing the trailing '/.default' if present. + """Extracts the resource from the given scope by removing the last '/' and everything after it. :param scope: The scope string. :return: The extracted resource string. diff --git a/tests/authentication_msal/test_msal_token_credential.py b/tests/authentication_msal/test_msal_token_credential.py index 3c9a666e..a6c6ed78 100644 --- a/tests/authentication_msal/test_msal_token_credential.py +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -5,6 +5,7 @@ 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" @@ -21,6 +22,23 @@ def auth_config() -> AgentAuthConfiguration: ) +@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"), + ("api://client-id/access_as_user", "api://client-id"), + ("resource/scope/with/segments", "resource/scope/with"), + ("resource/", "resource"), + ("/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_returns_access_token_and_forwards_scopes( mocker, @@ -37,7 +55,7 @@ async def test_get_token_returns_access_token_and_forwards_scopes( assert token is expected_token msal_auth_class.assert_called_once_with(auth_config) msal_auth._get_access_token.assert_awaited_once_with( - _FIRST_SCOPE, + "https://api.botframework.com", [_FIRST_SCOPE, _SECOND_SCOPE], ) From 1d98e107a35a92e427daf75d2f8396af0476d775 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 10:45:46 -0700 Subject: [PATCH 05/14] Fixing _get_resource --- .../authentication/msal/msal_token_credential.py | 8 ++------ .../test_msal_token_credential.py | 15 ++++++++++----- 2 files changed, 12 insertions(+), 11 deletions(-) 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 index 05329fa9..47cc5671 100644 --- 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 @@ -14,17 +14,13 @@ def _get_resource(scope: str) -> str: - """Extracts the resource from the given scope by removing the last '/' and everything after it. + """Extracts the resource by removing a trailing '/.default' from the scope. :param scope: The scope string. :return: The extracted resource string. :rtype: str """ - try: - i = scope.rindex("/") - return scope[:i] - except ValueError: - return scope + return scope.removesuffix("/.default") class MsalTokenCredential(AsyncTokenCredential): diff --git a/tests/authentication_msal/test_msal_token_credential.py b/tests/authentication_msal/test_msal_token_credential.py index a6c6ed78..efd21ab1 100644 --- a/tests/authentication_msal/test_msal_token_credential.py +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -26,11 +26,16 @@ def auth_config() -> AgentAuthConfiguration: "scope, expected_resource", [ ("https://api.botframework.com/.default", "https://api.botframework.com"), - ("https://graph.microsoft.com/User.Read", "https://graph.microsoft.com"), - ("api://client-id/access_as_user", "api://client-id"), - ("resource/scope/with/segments", "resource/scope/with"), - ("resource/", "resource"), - ("/scope", ""), + ( + "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"), ("", ""), ], From d4fbcfc2c0b2d22c4dd11a3dd2cbfa87178cedad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 27 Aug 2026 11:45:34 -0700 Subject: [PATCH 06/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../authentication/msal/msal_token_credential.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 47cc5671..000c6f04 100644 --- 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 @@ -33,6 +33,7 @@ def __init__(self, config: AgentAuthConfiguration): :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` """ self._config = config + self._provider = MsalAuth(config) async def get_token(self, *scopes: str, **kwargs) -> AccessToken: """Acquire an access token for the specified scopes. @@ -51,5 +52,4 @@ async def get_token(self, *scopes: str, **kwargs) -> AccessToken: resource = _get_resource(scopes[0]) - provider = MsalAuth(self._config) - return await provider._get_access_token(resource, list(scopes)) + return await self._provider._get_access_token(resource, list(scopes)) From e33c116b986e9433bb0f21d9033b6130a1b681aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 27 Aug 2026 11:45:47 -0700 Subject: [PATCH 07/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libraries/microsoft-agents-authentication-msal/readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-authentication-msal/readme.md b/libraries/microsoft-agents-authentication-msal/readme.md index a3b2f6e9..8b94abdc 100644 --- a/libraries/microsoft-agents-authentication-msal/readme.md +++ b/libraries/microsoft-agents-authentication-msal/readme.md @@ -247,7 +247,8 @@ token = await credential.get_token("https://api.botframework.com/.default") ``` At least one scope is required. The first scope must be an absolute resource -URI, and all requested scopes are passed to MSAL during token acquisition. +URI (typically ending in `/.default`). For client-credential flows, all requested +scopes are passed to MSAL; managed identity uses the derived resource. ## Features From 26347f493d5c25be7b7198d780807ee5e5710c9e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 13:32:55 -0700 Subject: [PATCH 08/14] Update --- changelog.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/changelog.md b/changelog.md index 120940e2..4cffbb72 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,14 @@ +# Microsoft 365 Agents SDK for Python - Release Notes v1.6.0 + +**Release Date:** Unreleased +**Previous Version:** 1.5.0 (Released 2026-08-26) + +## Bug Fixes + +- **MSAL Resource Extraction**: Preserved `api://` and other non-default scopes while removing only a trailing `/.default` when deriving the authentication resource. + +--- + # Microsoft 365 Agents SDK for Python - Release Notes v1.5.0 **Release Date:** 2026-08-26 From 79e30e4143435a8da46b5f6e61cf1e69d48f7e3c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 13:34:29 -0700 Subject: [PATCH 09/14] Removing section from README --- .../readme.md | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/readme.md b/libraries/microsoft-agents-authentication-msal/readme.md index 8b94abdc..fe51e9b5 100644 --- a/libraries/microsoft-agents-authentication-msal/readme.md +++ b/libraries/microsoft-agents-authentication-msal/readme.md @@ -219,37 +219,6 @@ class AuthTypes(str, Enum): - **`MsalConnectionManager`** - Manages multiple authentication connections - **`MsalTokenCredential`** - Asynchronous Azure Core token credential backed by MSAL -## Azure Core Token Credential - -`MsalTokenCredential` adapts an `AgentAuthConfiguration` to the Azure Core -`AsyncTokenCredential` interface. Use it with asynchronous Azure SDK clients or -other libraries that accept an `AsyncTokenCredential`. - -Create the authentication configuration with the client ID, tenant ID, and -client secret for your application: - -```python -import os - -from microsoft_agents.authentication.msal import MsalTokenCredential -from microsoft_agents.hosting.core import AgentAuthConfiguration - -auth_config = AgentAuthConfiguration( - client_id=os.environ["CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID"], - client_secret=os.environ[ - "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET" - ], - tenant_id=os.environ["CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID"], -) - -credential = MsalTokenCredential(auth_config) -token = await credential.get_token("https://api.botframework.com/.default") -``` - -At least one scope is required. The first scope must be an absolute resource -URI (typically ending in `/.default`). For client-credential flows, all requested -scopes are passed to MSAL; managed identity uses the derived resource. - ## Features ✅ **Multiple auth types** - Client secret, certificate, managed identity From 24c5a9aef3ce0b5b5aa61732c3ea2c88a9c66d47 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 13:43:47 -0700 Subject: [PATCH 10/14] Lazy construction of MsalAuth instance --- .../msal/msal_token_credential.py | 5 +++- .../test_msal_token_credential.py | 26 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) 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 index 000c6f04..933eebd0 100644 --- 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 @@ -33,7 +33,7 @@ def __init__(self, config: AgentAuthConfiguration): :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` """ self._config = config - self._provider = MsalAuth(config) + self._provider: MsalAuth | None = None async def get_token(self, *scopes: str, **kwargs) -> AccessToken: """Acquire an access token for the specified scopes. @@ -50,6 +50,9 @@ async def get_token(self, *scopes: str, **kwargs) -> AccessToken: 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/tests/authentication_msal/test_msal_token_credential.py b/tests/authentication_msal/test_msal_token_credential.py index efd21ab1..ba59279c 100644 --- a/tests/authentication_msal/test_msal_token_credential.py +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -45,7 +45,7 @@ def test_get_resource(scope: str, expected_resource: str): @pytest.mark.asyncio -async def test_get_token_returns_access_token_and_forwards_scopes( +async def test_get_token_lazily_creates_provider_and_forwards_scopes( mocker, auth_config: AgentAuthConfiguration, ): @@ -54,10 +54,12 @@ async def test_get_token_returns_access_token_and_forwards_scopes( 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, _SECOND_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", @@ -65,6 +67,28 @@ async def test_get_token_returns_access_token_and_forwards_scopes( ) +@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, From 445bd324bb5a1bb1a19df20c451674944f27e85b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 13:51:45 -0700 Subject: [PATCH 11/14] Fixing changelog and tests --- changelog.md | 6 +++--- tests/authentication_msal/test_msal_token_credential.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/changelog.md b/changelog.md index 4cffbb72..e20fdd34 100644 --- a/changelog.md +++ b/changelog.md @@ -1,11 +1,11 @@ -# Microsoft 365 Agents SDK for Python - Release Notes v1.6.0 +# Microsoft 365 Agents SDK for Python - Release Notes v1.6.1 (Unreleased) **Release Date:** Unreleased **Previous Version:** 1.5.0 (Released 2026-08-26) -## Bug Fixes +## New Models & APIs -- **MSAL Resource Extraction**: Preserved `api://` and other non-default scopes while removing only a trailing `/.default` when deriving the authentication resource. +- **MSAL Token Credential**: Added `MsalTokenCredential`, an Azure Core-compatible asynchronous token credential backed by MSAL, for authenticating Azure SDK clients that accept an `AsyncTokenCredential`. --- diff --git a/tests/authentication_msal/test_msal_token_credential.py b/tests/authentication_msal/test_msal_token_credential.py index ba59279c..78888a8f 100644 --- a/tests/authentication_msal/test_msal_token_credential.py +++ b/tests/authentication_msal/test_msal_token_credential.py @@ -45,7 +45,7 @@ def test_get_resource(scope: str, expected_resource: str): @pytest.mark.asyncio -async def test_get_token_lazily_creates_provider_and_forwards_scopes( +async def test_get_token_lazily_creates_provider_and_forwards_scope( mocker, auth_config: AgentAuthConfiguration, ): @@ -56,14 +56,14 @@ async def test_get_token_lazily_creates_provider_and_forwards_scopes( credential = MsalTokenCredential(auth_config) msal_auth_class.assert_not_called() - token = await credential.get_token(_FIRST_SCOPE, _SECOND_SCOPE) + 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, _SECOND_SCOPE], + [_FIRST_SCOPE], ) From 186b03cb20b18ffb90879c5c7d2cf558aadc4aa9 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 14:03:59 -0700 Subject: [PATCH 12/14] Improving integration test --- .../tests/auth/test_msal_token_credential.py | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/dev/integration/tests/auth/test_msal_token_credential.py b/dev/integration/tests/auth/test_msal_token_credential.py index e5a9e38f..6ac49ce1 100644 --- a/dev/integration/tests/auth/test_msal_token_credential.py +++ b/dev/integration/tests/auth/test_msal_token_credential.py @@ -1,12 +1,15 @@ # 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 @@ -17,7 +20,8 @@ _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_SCOPE = "https://api.botframework.com/.default" +_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( @@ -36,7 +40,7 @@ def auth_config() -> AgentAuthConfiguration: @pytest.mark.asyncio -async def test_msal_token_credential_acquires_token( +async def test_msal_token_credential_acquires_valid_token( auth_config: AgentAuthConfiguration, ): credential = MsalTokenCredential(auth_config) @@ -46,3 +50,37 @@ async def test_msal_token_credential_acquires_token( 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 From 1a69c71119e0b2487425a17313b62089a1780750 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 27 Aug 2026 14:05:49 -0700 Subject: [PATCH 13/14] Adding direct dependency on azure.core in microsoft-agents-authentication-msal --- libraries/microsoft-agents-authentication-msal/setup.py | 1 + 1 file changed, 1 insertion(+) 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", ], From d4ef5e297648075c6c230a63f53a749b21b73670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Brand=C3=A3o?= Date: Thu, 27 Aug 2026 14:14:54 -0700 Subject: [PATCH 14/14] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../microsoft_agents/authentication/msal/msal_auth.py | 4 ++++ 1 file changed, 4 insertions(+) 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 83afbd3a..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 @@ -138,6 +138,10 @@ async def _get_access_token( ) ) + 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.")