Skip to content

Commit 5857b2a

Browse files
MattB-msftCopilotCopilot
authored
Enhance MsalAuth for multi-tenant support, update MSAL version, and add JWT decoding functionality with adaptive card integration (#307)
* Enhance MsalAuth for multi-tenant support, update MSAL version, and add JWT decoding functionality with adaptive card integration * Update test_samples/agentic-test/src/agent.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor: Improve regex formatting and comment clarity in MsalAuth class * Add test coverage for multi-tenant auth methods and fix regex pattern bug (#309) * Initial plan * Add comprehensive test coverage for _resolve_authority and _resolve_tenant_id methods; fix regex bug Co-authored-by: MattB-msft <10568244+MattB-msft@users.noreply.github.com> * Address code review feedback: improve test naming and add clarifying comment Co-authored-by: MattB-msft <10568244+MattB-msft@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MattB-msft <10568244+MattB-msft@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: MattB-msft <10568244+MattB-msft@users.noreply.github.com>
1 parent d6e4106 commit 5857b2a

7 files changed

Lines changed: 268 additions & 8 deletions

File tree

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,11 @@ def _resolve_authority(
163163
)
164164

165165
if config.AUTHORITY:
166-
return re.sub(r"/common(?=/|$)", f"/{tenant_id}", config.AUTHORITY)
166+
return re.sub(
167+
r"/(?:common|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?=/|$)",
168+
f"/{tenant_id}",
169+
config.AUTHORITY,
170+
)
167171

168172
return f"https://login.microsoftonline.com/{tenant_id}"
169173

@@ -177,7 +181,7 @@ def _resolve_tenant_id(
177181
return tenant_id
178182
raise ValueError("TENANT_ID is not set in the configuration.")
179183

180-
if tenant_id and config.TENANT_ID.lower() == "common":
184+
if tenant_id or config.TENANT_ID.lower() == "common":
181185
return tenant_id
182186

183187
return config.TENANT_ID
@@ -366,7 +370,7 @@ async def get_agentic_instance_token(
366370
client_id=agent_app_instance_id,
367371
authority=authority,
368372
client_credential={"client_assertion": agent_token_result},
369-
token_cache=self._token_cache,
373+
# token_cache=self._token_cache,
370374
)
371375

372376
agentic_instance_token = await _async_acquire_token_for_client(
@@ -458,7 +462,7 @@ async def get_agentic_user_token(
458462
client_id=agent_app_instance_id,
459463
authority=authority,
460464
client_credential={"client_assertion": agent_token},
461-
token_cache=self._token_cache,
465+
# token_cache=self._token_cache,
462466
)
463467

464468
logger.info(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
version=package_version,
1414
install_requires=[
1515
f"microsoft-agents-hosting-core=={package_version}",
16-
"msal>=1.31.1",
16+
"msal>=1.34.0",
1717
"requests>=2.32.3",
1818
"cryptography>=44.0.0",
1919
],
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"type": "AdaptiveCard",
3+
"$schema": "https://adaptivecards.io/schemas/adaptive-card.json",
4+
"version": "1.6",
5+
"body": [
6+
{
7+
"type": "TextBlock",
8+
"text": "Agentic Message Received - Token Decode",
9+
"wrap": true,
10+
"weight": "Bolder"
11+
},
12+
{
13+
"type": "TextBlock",
14+
"text": "Token Decode:",
15+
"wrap": true
16+
},
17+
{
18+
"type": "ColumnSet",
19+
"columns": [
20+
{
21+
"type": "Column",
22+
"width": "stretch",
23+
"items": [
24+
{
25+
"type": "FactSet",
26+
"facts": [
27+
{
28+
"title": "Token Length",
29+
"value": "{{length}}"
30+
},
31+
{
32+
"title": "Name",
33+
"value": "{{name}}"
34+
},
35+
{
36+
"title": "Upn",
37+
"value": "{{upn}}"
38+
},
39+
{
40+
"title": "Oid",
41+
"value": "{{oid}}"
42+
},
43+
{
44+
"title": "Tid",
45+
"value": "{{tid}}"
46+
}
47+
]
48+
}
49+
]
50+
}
51+
]
52+
}
53+
]
54+
}

test_samples/agentic-test/src/agent.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# Licensed under the MIT License.
33

44
import logging
5+
import os
6+
import jwt
57
from dotenv import load_dotenv
68

79
from os import environ
@@ -12,13 +14,16 @@
1214
TurnState,
1315
TurnContext,
1416
MemoryStorage,
17+
MessageFactory,
1518
)
1619
from microsoft_agents.hosting.core.storage import (
1720
TranscriptLoggerMiddleware,
1821
ConsoleTranscriptLogger,
1922
)
2023
from microsoft_agents.authentication.msal import MsalConnectionManager
21-
from microsoft_agents.activity import load_configuration_from_env
24+
from microsoft_agents.activity import load_configuration_from_env, Attachment
25+
26+
from jwtcard import load_adaptive_card, update_card_data
2227

2328
logger = logging.getLogger(__name__)
2429

@@ -38,7 +43,25 @@
3843

3944
@AGENT_APP.activity("message", auth_handlers=["AGENTIC"])
4045
async def on_message(context: TurnContext, _state: TurnState):
46+
4147
aau_token = await AGENT_APP.auth.get_token(context, "AGENTIC")
48+
decoded = jwt.decode(aau_token.token, options={"verify_signature": False})
49+
decoded["length"] = len(aau_token.token)
50+
51+
relative_path = os.path.abspath(os.path.dirname(__file__))
52+
template_path = os.path.join(relative_path, "JWTDecodeCard.json")
53+
54+
card = load_adaptive_card(template_path)
55+
populated_card = update_card_data(card, decoded)
56+
57+
attachment = MessageFactory.attachment(
58+
Attachment(
59+
content_type="application/vnd.microsoft.card.adaptive",
60+
content=populated_card,
61+
)
62+
)
63+
await context.send_activity(attachment)
64+
4265
await context.send_activity(
4366
f"Acquired agentic user token with length: {len(aau_token.token)}"
4467
)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import json
2+
import os
3+
4+
5+
def load_adaptive_card(file_path):
6+
"""Load Adaptive Card JSON from a file with validation."""
7+
if not os.path.exists(file_path):
8+
raise FileNotFoundError(f"Adaptive Card file not found: {file_path}")
9+
10+
with open(file_path, "r", encoding="utf-8") as file:
11+
try:
12+
card_json = json.load(file)
13+
except json.JSONDecodeError as e:
14+
raise ValueError(f"Invalid JSON in {file_path}: {e}")
15+
16+
return card_json
17+
18+
19+
def update_card_data(card_json, data_map):
20+
"""
21+
Replace placeholders in the Adaptive Card JSON with actual values.
22+
Placeholders should be in the form {{key}} in the JSON template.
23+
"""
24+
card_str = json.dumps(card_json) # Convert to string for replacement
25+
for key, value in data_map.items():
26+
placeholder = f"{{{{{key}}}}}" # e.g., {{name}}
27+
card_str = card_str.replace(placeholder, str(value))
28+
29+
return json.loads(card_str) # Convert back to dict

test_samples/agentic-test/src/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
ms_agents_logger.setLevel(logging.DEBUG)
1717

1818

19-
from .agent import AGENT_APP, CONNECTION_MANAGER
20-
from .start_server import start_server
19+
from agent import AGENT_APP, CONNECTION_MANAGER
20+
from start_server import start_server
2121

2222
start_server(
2323
agent_application=AGENT_APP,

tests/authentication_msal/test_msal_auth.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
from microsoft_agents.authentication.msal import MsalAuth
55
from microsoft_agents.hosting.core import Connections
6+
from microsoft_agents.hosting.core.authorization import (
7+
AgentAuthConfiguration,
8+
AuthTypes,
9+
)
610

711
from tests._common.testing_objects import MockMsalAuth
812

@@ -63,6 +67,152 @@ async def test_acquire_token_on_behalf_of_confidential(self, mocker):
6367
)
6468

6569

70+
class TestMsalAuthTenantResolution:
71+
"""
72+
Test suite for testing tenant resolution methods in MsalAuth.
73+
These methods are critical for multi-tenant authentication support.
74+
"""
75+
76+
def test_resolve_tenant_id_with_override_parameter(self):
77+
"""Test that tenant_id parameter takes precedence when provided"""
78+
config = AgentAuthConfiguration(
79+
tenant_id="12345678-1234-1234-1234-123456789abc"
80+
)
81+
result = MsalAuth._resolve_tenant_id(config, "tenant-override")
82+
assert result == "tenant-override"
83+
84+
def test_resolve_tenant_id_with_common_and_tenant_parameter(self):
85+
"""Test that tenant_id parameter is used when config.TENANT_ID is 'common'"""
86+
config = AgentAuthConfiguration(tenant_id="common")
87+
result = MsalAuth._resolve_tenant_id(config, "specific-tenant")
88+
assert result == "specific-tenant"
89+
90+
def test_resolve_tenant_id_with_common_no_tenant_parameter(self):
91+
"""Test that None is returned when config.TENANT_ID is 'common' and no tenant_id provided"""
92+
config = AgentAuthConfiguration(tenant_id="common")
93+
result = MsalAuth._resolve_tenant_id(config, None)
94+
assert result is None
95+
96+
def test_resolve_tenant_id_with_specific_tenant(self):
97+
"""Test that config.TENANT_ID is returned when it's a specific value"""
98+
config = AgentAuthConfiguration(
99+
tenant_id="12345678-1234-1234-1234-123456789abc"
100+
)
101+
result = MsalAuth._resolve_tenant_id(config, None)
102+
assert result == "12345678-1234-1234-1234-123456789abc"
103+
104+
def test_resolve_tenant_id_no_config_tenant_with_parameter(self):
105+
"""Test that tenant_id parameter is used when config.TENANT_ID is not set.
106+
Note: tenant_id can be any string, not just GUID format."""
107+
config = AgentAuthConfiguration()
108+
result = MsalAuth._resolve_tenant_id(config, "fallback-tenant")
109+
assert result == "fallback-tenant"
110+
111+
def test_resolve_tenant_id_no_config_tenant_no_parameter(self):
112+
"""Test that ValueError is raised when neither config.TENANT_ID nor tenant_id are set"""
113+
config = AgentAuthConfiguration()
114+
with pytest.raises(
115+
ValueError, match="TENANT_ID is not set in the configuration"
116+
):
117+
MsalAuth._resolve_tenant_id(config, None)
118+
119+
def test_resolve_authority_with_common_replacement(self):
120+
"""Test that /common is replaced with the resolved tenant_id in authority URL"""
121+
config = AgentAuthConfiguration(
122+
tenant_id="12345678-1234-1234-1234-123456789abc",
123+
authority="https://login.microsoftonline.com/common",
124+
)
125+
result = MsalAuth._resolve_authority(config, None)
126+
assert (
127+
result
128+
== "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc"
129+
)
130+
131+
def test_resolve_authority_with_tenant_guid_replacement(self):
132+
"""Test that existing tenant GUID is replaced with new tenant_id in authority URL"""
133+
config = AgentAuthConfiguration(
134+
tenant_id="12345678-1234-1234-1234-123456789abc",
135+
authority="https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
136+
)
137+
result = MsalAuth._resolve_authority(
138+
config, "new-tenant-11111111-2222-3333-4444-555555555555"
139+
)
140+
assert (
141+
result
142+
== "https://login.microsoftonline.com/new-tenant-11111111-2222-3333-4444-555555555555"
143+
)
144+
145+
def test_resolve_authority_with_common_and_tenant_parameter(self):
146+
"""Test that /common is replaced with provided tenant_id parameter"""
147+
config = AgentAuthConfiguration(
148+
tenant_id="common", authority="https://login.microsoftonline.com/common"
149+
)
150+
result = MsalAuth._resolve_authority(
151+
config, "override-22222222-3333-4444-5555-666666666666"
152+
)
153+
assert (
154+
result
155+
== "https://login.microsoftonline.com/override-22222222-3333-4444-5555-666666666666"
156+
)
157+
158+
def test_resolve_authority_no_authority_configured(self):
159+
"""Test fallback to default URL when no authority is configured"""
160+
config = AgentAuthConfiguration(
161+
tenant_id="12345678-1234-1234-1234-123456789abc"
162+
)
163+
result = MsalAuth._resolve_authority(config, None)
164+
assert (
165+
result
166+
== "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc"
167+
)
168+
169+
def test_resolve_authority_no_authority_with_tenant_override(self):
170+
"""Test fallback to default URL with tenant override when no authority is configured"""
171+
config = AgentAuthConfiguration(
172+
tenant_id="12345678-1234-1234-1234-123456789abc"
173+
)
174+
result = MsalAuth._resolve_authority(
175+
config, "override-99999999-8888-7777-6666-555555555555"
176+
)
177+
assert (
178+
result
179+
== "https://login.microsoftonline.com/override-99999999-8888-7777-6666-555555555555"
180+
)
181+
182+
def test_resolve_authority_with_common_no_tenant_parameter(self):
183+
"""Test behavior when config.TENANT_ID is 'common' and no tenant_id parameter"""
184+
config = AgentAuthConfiguration(
185+
tenant_id="common", authority="https://login.microsoftonline.com/common"
186+
)
187+
# When tenant_id is None after resolution, should return original authority
188+
result = MsalAuth._resolve_authority(config, None)
189+
assert result == "https://login.microsoftonline.com/common"
190+
191+
def test_resolve_authority_regex_with_trailing_slash(self):
192+
"""Test that regex correctly handles authority URLs with trailing slashes"""
193+
config = AgentAuthConfiguration(
194+
tenant_id="12345678-1234-1234-1234-123456789abc",
195+
authority="https://login.microsoftonline.com/common/",
196+
)
197+
result = MsalAuth._resolve_authority(config, None)
198+
assert (
199+
result
200+
== "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/"
201+
)
202+
203+
def test_resolve_authority_regex_preserves_path(self):
204+
"""Test that regex correctly replaces tenant while preserving additional path segments"""
205+
config = AgentAuthConfiguration(
206+
tenant_id="12345678-1234-1234-1234-123456789abc",
207+
authority="https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
208+
)
209+
result = MsalAuth._resolve_authority(config, None)
210+
assert (
211+
result
212+
== "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/oauth2/v2.0/authorize"
213+
)
214+
215+
66216
# class TestMsalAuthAgentic:
67217

68218
# @pytest.mark.asyncio

0 commit comments

Comments
 (0)