Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
86 changes: 86 additions & 0 deletions dev/integration/tests/auth/test_msal_token_credential.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 13 additions & 3 deletions dev/integration/tests/utils/pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)}"
),
)
Original file line number Diff line number Diff line change
@@ -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",
]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@
SystemAssignedManagedIdentity,
TokenCache,
)
from azure.core.credentials import AccessToken
from requests import Session
Comment thread
rodrigobr-msft marked this conversation as resolved.

from microsoft_agents.activity._utils import _DeferredString
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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))
Comment thread
Copilot marked this conversation as resolved.

async def acquire_token_on_behalf_of(
self, scopes: list[str], user_assertion: str
Expand Down Expand Up @@ -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.
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Comment thread
rodrigobr-msft marked this conversation as resolved.
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))
Comment thread
rodrigobr-msft marked this conversation as resolved.
1 change: 1 addition & 0 deletions libraries/microsoft-agents-authentication-msal/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions libraries/microsoft-agents-authentication-msal/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
5 changes: 4 additions & 1 deletion tests/_common/testing_objects/mocks/mock_msal_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
):
Comment thread
rodrigobr-msft marked this conversation as resolved.
super().__init__(AgentAuthConfiguration())
mock_client = mocker.Mock(spec=client_type)
Expand Down
23 changes: 23 additions & 0 deletions tests/authentication_msal/test_msal_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading