Skip to content

Commit b0f0083

Browse files
Adding MsalTokenCredential implementation of azure.core.credentials.AsyncTokenProvider (#565)
* MsalTokenCredential and AsyncMsalTokenCredential definitions * Adding internal _get_access_token method * Improved handling of resource from scope * Improvements * Fixing _get_resource * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Update * Removing section from README * Lazy construction of MsalAuth instance * Fixing changelog and tests * Improving integration test * Adding direct dependency on azure.core in microsoft-agents-authentication-msal --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent f7b8f39 commit b0f0083

11 files changed

Lines changed: 359 additions & 6 deletions

File tree

changelog.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
# Microsoft 365 Agents SDK for Python - Release Notes v1.6.1 (Unreleased)
2+
3+
**Release Date:** Unreleased
4+
**Previous Version:** 1.5.0 (Released 2026-08-26)
5+
6+
## New Models & APIs
7+
8+
- **MSAL Token Credential**: Added `MsalTokenCredential`, an Azure Core-compatible asynchronous token credential backed by MSAL, for authenticating Azure SDK clients that accept an `AsyncTokenCredential`.
9+
10+
---
11+
112
# Microsoft 365 Agents SDK for Python - Release Notes v1.5.0
213

314
**Release Date:** 2026-08-26
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import asyncio
5+
import os
6+
import time
7+
8+
import jwt
9+
import pytest
10+
from azure.core.credentials import AccessToken
11+
from dotenv import dotenv_values
12+
from jwt import PyJWKClient
13+
14+
from microsoft_agents.authentication.msal import MsalTokenCredential
15+
from microsoft_agents.hosting.core import AgentAuthConfiguration
16+
17+
from tests.utils.config import REAL_SERVICE_CONNECTION_ENV_VARS
18+
from tests.utils.pytest import skip_if_no_var
19+
20+
_CLIENT_ID_ENV_VAR = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID"
21+
_CLIENT_SECRET_ENV_VAR = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET"
22+
_TENANT_ID_ENV_VAR = "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID"
23+
_BOT_FRAMEWORK_RESOURCE = "https://api.botframework.com"
24+
_BOT_FRAMEWORK_SCOPE = f"{_BOT_FRAMEWORK_RESOURCE}/.default"
25+
_ENVIRONMENT = {**dotenv_values(".env"), **os.environ}
26+
27+
pytestmark = skip_if_no_var(
28+
*REAL_SERVICE_CONNECTION_ENV_VARS,
29+
environ=_ENVIRONMENT,
30+
)
31+
32+
33+
@pytest.fixture
34+
def auth_config() -> AgentAuthConfiguration:
35+
return AgentAuthConfiguration(
36+
client_id=_ENVIRONMENT[_CLIENT_ID_ENV_VAR],
37+
client_secret=_ENVIRONMENT[_CLIENT_SECRET_ENV_VAR],
38+
tenant_id=_ENVIRONMENT[_TENANT_ID_ENV_VAR],
39+
)
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_msal_token_credential_acquires_valid_token(
44+
auth_config: AgentAuthConfiguration,
45+
):
46+
credential = MsalTokenCredential(auth_config)
47+
48+
token = await credential.get_token(_BOT_FRAMEWORK_SCOPE)
49+
50+
assert isinstance(token, AccessToken)
51+
assert token.token
52+
assert token.expires_on > time.time()
53+
54+
unverified_claims = jwt.decode(
55+
token.token,
56+
options={"verify_signature": False},
57+
)
58+
token_version = unverified_claims.get("ver")
59+
assert token_version in ("1.0", "2.0")
60+
61+
tenant_id = auth_config.TENANT_ID
62+
issuer = (
63+
f"https://login.microsoftonline.com/{tenant_id}/v2.0"
64+
if token_version == "2.0"
65+
else f"https://sts.windows.net/{tenant_id}/"
66+
)
67+
jwks_client = PyJWKClient(
68+
f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
69+
)
70+
signing_key = await asyncio.to_thread(
71+
jwks_client.get_signing_key_from_jwt,
72+
token.token,
73+
)
74+
75+
claims = jwt.decode(
76+
token.token,
77+
signing_key.key,
78+
algorithms=["RS256"],
79+
audience=_BOT_FRAMEWORK_RESOURCE,
80+
issuer=issuer,
81+
)
82+
83+
assert claims["tid"] == tenant_id
84+
assert abs(claims["exp"] - token.expires_on) <= 5
85+
client_id_claim = "azp" if token_version == "2.0" else "appid"
86+
assert claims[client_id_claim] == auth_config.CLIENT_ID

dev/integration/tests/utils/pytest.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
# Licensed under the MIT License.
33

44
import os
5+
56
import pytest
67
from dotenv import dotenv_values
78

8-
def skip_if_no_var(*env_vars: str, environ: dict | None = None, load_root_env_file: bool = False):
9+
10+
def skip_if_no_var(
11+
*env_vars: str,
12+
environ: dict | None = None,
13+
load_root_env_file: bool = False,
14+
):
915
"""Skip the test if any of the specified environment variables are not set.
1016
1117
: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
1521
if load_root_env_file:
1622
# Load environment variables from the root .env file if specified
1723
environ = {**os.environ, **dotenv_values(".env")}
24+
environment = os.environ if environ is None else environ
1825
return pytest.mark.skipif(
19-
any(env_var not in (environ or os.environ) for env_var in env_vars),
20-
reason=f"Skipping test because one or more environment variables are not set: {', '.join(env_vars)}"
26+
any(not environment.get(env_var) for env_var in env_vars),
27+
reason=(
28+
"Skipping test because one or more environment variables are not set: "
29+
f"{', '.join(env_vars)}"
30+
),
2131
)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
14
from .msal_auth import MsalAuth
25
from .msal_connection_manager import MsalConnectionManager
6+
from .msal_token_credential import MsalTokenCredential
37

48
__all__ = [
59
"MsalAuth",
610
"MsalConnectionManager",
11+
"MsalTokenCredential",
712
]

libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
import asyncio
88
import logging
9+
import time
910
import jwt
1011
from typing import Optional
1112
from urllib.parse import urlparse, ParseResult as URI
@@ -16,6 +17,7 @@
1617
SystemAssignedManagedIdentity,
1718
TokenCache,
1819
)
20+
from azure.core.credentials import AccessToken
1921
from requests import Session
2022

2123
from microsoft_agents.activity._utils import _DeferredString
@@ -73,6 +75,28 @@ def configuration(self) -> AgentAuthConfiguration:
7375
async def get_access_token(
7476
self, resource_url: str, scopes: list[str], force_refresh: bool = False
7577
) -> str:
78+
"""Gets an access token for the specified resource URL and scopes.
79+
80+
:param resource_url: The resource URL for which to acquire the access token.
81+
:param scopes: The scopes for which the access token is requested.
82+
:param force_refresh: Whether to force a refresh of the access token.
83+
:return: The acquired access token as a string.
84+
:rtype: str
85+
"""
86+
access_token = await self._get_access_token(resource_url, scopes, force_refresh)
87+
return access_token.token
88+
89+
async def _get_access_token(
90+
self, resource_url: str, scopes: list[str], force_refresh: bool = False
91+
) -> AccessToken:
92+
"""Internal method to get an access token for the specified resource URL and scopes.
93+
94+
:param resource_url: The resource URL for which to acquire the access token.
95+
:param scopes: The scopes for which the access token is requested.
96+
:param force_refresh: Whether to force a refresh of the access token.
97+
:return: The acquired access token as an AccessToken object.
98+
:rtype: AccessToken
99+
"""
76100
with spans.GetAccessToken(
77101
scopes,
78102
self._msal_configuration.AUTH_TYPE,
@@ -99,7 +123,7 @@ async def get_access_token(
99123
msal_auth_client, scopes=local_scopes
100124
)
101125
else:
102-
auth_result_payload = None
126+
auth_result_payload = {}
103127

104128
res = (
105129
auth_result_payload.get("access_token") if auth_result_payload else None
@@ -114,7 +138,15 @@ async def get_access_token(
114138
)
115139
)
116140

117-
return res
141+
expires_on = auth_result_payload.get("expires_on")
142+
if expires_on is not None:
143+
return AccessToken(res, int(expires_on))
144+
145+
expires_in = auth_result_payload.get("expires_in")
146+
if expires_in is None:
147+
raise ValueError("Token response does not include an expiration.")
148+
149+
return AccessToken(res, int(time.time()) + int(expires_in))
118150

119151
async def acquire_token_on_behalf_of(
120152
self, scopes: list[str], user_assertion: str
@@ -186,6 +218,11 @@ def _resolve_authority(
186218
def _resolve_azure_region(config: AgentAuthConfiguration) -> str | None:
187219
"""Resolves the Azure regional token service (ESTS-R) to use, if configured.
188220
221+
:param config: The agent authentication configuration.
222+
:type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`
223+
:return: The resolved Azure region or None if not configured.
224+
:rtype: str | None
225+
189226
Returns the configured region only when it is populated and non-whitespace,
190227
otherwise None so that MSAL falls back to the global token service.
191228
"""
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import logging
5+
6+
from azure.core.credentials import AccessToken
7+
from azure.core.credentials_async import AsyncTokenCredential
8+
9+
from microsoft_agents.hosting.core import AgentAuthConfiguration
10+
11+
from .msal_auth import MsalAuth
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
def _get_resource(scope: str) -> str:
17+
"""Extracts the resource by removing a trailing '/.default' from the scope.
18+
19+
:param scope: The scope string.
20+
:return: The extracted resource string.
21+
:rtype: str
22+
"""
23+
return scope.removesuffix("/.default")
24+
25+
26+
class MsalTokenCredential(AsyncTokenCredential):
27+
"""Provides an asynchronous Azure Core token credential using MSAL."""
28+
29+
def __init__(self, config: AgentAuthConfiguration):
30+
"""Initializes the MsalTokenCredential with the given configuration.
31+
32+
:param config: The agent authentication configuration.
33+
:type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`
34+
"""
35+
self._config = config
36+
self._provider: MsalAuth | None = None
37+
38+
async def get_token(self, *scopes: str, **kwargs) -> AccessToken:
39+
"""Acquire an access token for the specified scopes.
40+
41+
:param scopes: The scopes for which the access token is requested.
42+
:param kwargs: Additional keyword arguments.
43+
44+
:return: The acquired access token.
45+
:rtype: AccessToken
46+
"""
47+
48+
logger.debug("get_token scope=%s", scopes)
49+
50+
if not scopes:
51+
raise ValueError("At least one scope must be provided.")
52+
53+
if not self._provider:
54+
self._provider = MsalAuth(self._config)
55+
56+
resource = _get_resource(scopes[0])
57+
58+
return await self._provider._get_access_token(resource, list(scopes))

libraries/microsoft-agents-authentication-msal/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ class AuthTypes(str, Enum):
217217

218218
- **`MsalAuth`** - Core authentication provider using MSAL
219219
- **`MsalConnectionManager`** - Manages multiple authentication connections
220+
- **`MsalTokenCredential`** - Asynchronous Azure Core token credential backed by MSAL
220221

221222
## Features
222223

libraries/microsoft-agents-authentication-msal/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
version=package_version,
1414
install_requires=[
1515
f"microsoft-agents-hosting-core=={package_version}",
16+
"azure-core",
1617
"msal>=1.34.0",
1718
"requests>=2.32.3",
1819
],

tests/_common/testing_objects/mocks/mock_msal_auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ def __init__(
1212
self,
1313
mocker,
1414
client_type,
15-
acquire_token_for_client_return={"access_token": "token"},
15+
acquire_token_for_client_return={
16+
"access_token": "token",
17+
"expires_in": 3600,
18+
},
1619
):
1720
super().__init__(AgentAuthConfiguration())
1821
mock_client = mocker.Mock(spec=client_type)

tests/authentication_msal/test_msal_auth.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ async def test_get_access_token_confidential(self, mocker):
4040
scopes=["test-scope"]
4141
)
4242

43+
@pytest.mark.asyncio
44+
async def test_get_access_token_converts_expires_in_to_expires_on(self, mocker):
45+
mock_auth = MockMsalAuth(
46+
mocker,
47+
ConfidentialClientApplication,
48+
{
49+
"access_token": "token",
50+
"expires_in": 3600,
51+
},
52+
)
53+
mocker.patch(
54+
"microsoft_agents.authentication.msal.msal_auth.time.time",
55+
return_value=1000,
56+
)
57+
58+
token = await mock_auth._get_access_token(
59+
"https://test.api.botframework.com",
60+
scopes=["test-scope"],
61+
)
62+
63+
assert token.token == "token"
64+
assert token.expires_on == 4600
65+
4366
@pytest.mark.asyncio
4467
async def test_acquire_token_on_behalf_of_managed_identity(self, mocker):
4568
mock_auth = MockMsalAuth(mocker, ManagedIdentityClient)

0 commit comments

Comments
 (0)