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 6eb77be3..8c46a7f9 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 @@ -163,7 +163,11 @@ def _resolve_authority( ) if config.AUTHORITY: - return re.sub(r"/common(?=/|$)", f"/{tenant_id}", config.AUTHORITY) + return re.sub( + 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})(?=/|$)", + f"/{tenant_id}", + config.AUTHORITY, + ) return f"https://login.microsoftonline.com/{tenant_id}" @@ -177,7 +181,7 @@ def _resolve_tenant_id( return tenant_id raise ValueError("TENANT_ID is not set in the configuration.") - if tenant_id and config.TENANT_ID.lower() == "common": + if tenant_id or config.TENANT_ID.lower() == "common": return tenant_id return config.TENANT_ID @@ -366,7 +370,7 @@ async def get_agentic_instance_token( client_id=agent_app_instance_id, authority=authority, client_credential={"client_assertion": agent_token_result}, - token_cache=self._token_cache, + # token_cache=self._token_cache, ) agentic_instance_token = await _async_acquire_token_for_client( @@ -458,7 +462,7 @@ async def get_agentic_user_token( client_id=agent_app_instance_id, authority=authority, client_credential={"client_assertion": agent_token}, - token_cache=self._token_cache, + # token_cache=self._token_cache, ) logger.info( diff --git a/libraries/microsoft-agents-authentication-msal/setup.py b/libraries/microsoft-agents-authentication-msal/setup.py index 6b4e9860..b68c2141 100644 --- a/libraries/microsoft-agents-authentication-msal/setup.py +++ b/libraries/microsoft-agents-authentication-msal/setup.py @@ -13,7 +13,7 @@ version=package_version, install_requires=[ f"microsoft-agents-hosting-core=={package_version}", - "msal>=1.31.1", + "msal>=1.34.0", "requests>=2.32.3", "cryptography>=44.0.0", ], diff --git a/test_samples/agentic-test/src/JWTDecodeCard.json b/test_samples/agentic-test/src/JWTDecodeCard.json new file mode 100644 index 00000000..e47b39ea --- /dev/null +++ b/test_samples/agentic-test/src/JWTDecodeCard.json @@ -0,0 +1,54 @@ +{ + "type": "AdaptiveCard", + "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": [ + { + "type": "TextBlock", + "text": "Agentic Message Received - Token Decode", + "wrap": true, + "weight": "Bolder" + }, + { + "type": "TextBlock", + "text": "Token Decode:", + "wrap": true + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "FactSet", + "facts": [ + { + "title": "Token Length", + "value": "{{length}}" + }, + { + "title": "Name", + "value": "{{name}}" + }, + { + "title": "Upn", + "value": "{{upn}}" + }, + { + "title": "Oid", + "value": "{{oid}}" + }, + { + "title": "Tid", + "value": "{{tid}}" + } + ] + } + ] + } + ] + } + ] +} diff --git a/test_samples/agentic-test/src/agent.py b/test_samples/agentic-test/src/agent.py index 9e843719..8d0040e3 100644 --- a/test_samples/agentic-test/src/agent.py +++ b/test_samples/agentic-test/src/agent.py @@ -2,6 +2,8 @@ # Licensed under the MIT License. import logging +import os +import jwt from dotenv import load_dotenv from os import environ @@ -12,13 +14,16 @@ TurnState, TurnContext, MemoryStorage, + MessageFactory, ) from microsoft_agents.hosting.core.storage import ( TranscriptLoggerMiddleware, ConsoleTranscriptLogger, ) from microsoft_agents.authentication.msal import MsalConnectionManager -from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.activity import load_configuration_from_env, Attachment + +from jwtcard import load_adaptive_card, update_card_data logger = logging.getLogger(__name__) @@ -38,7 +43,25 @@ @AGENT_APP.activity("message", auth_handlers=["AGENTIC"]) async def on_message(context: TurnContext, _state: TurnState): + aau_token = await AGENT_APP.auth.get_token(context, "AGENTIC") + decoded = jwt.decode(aau_token.token, options={"verify_signature": False}) + decoded["length"] = len(aau_token.token) + + relative_path = os.path.abspath(os.path.dirname(__file__)) + template_path = os.path.join(relative_path, "JWTDecodeCard.json") + + card = load_adaptive_card(template_path) + populated_card = update_card_data(card, decoded) + + attachment = MessageFactory.attachment( + Attachment( + content_type="application/vnd.microsoft.card.adaptive", + content=populated_card, + ) + ) + await context.send_activity(attachment) + await context.send_activity( f"Acquired agentic user token with length: {len(aau_token.token)}" ) diff --git a/test_samples/agentic-test/src/jwtcard.py b/test_samples/agentic-test/src/jwtcard.py new file mode 100644 index 00000000..ff66a78b --- /dev/null +++ b/test_samples/agentic-test/src/jwtcard.py @@ -0,0 +1,29 @@ +import json +import os + + +def load_adaptive_card(file_path): + """Load Adaptive Card JSON from a file with validation.""" + if not os.path.exists(file_path): + raise FileNotFoundError(f"Adaptive Card file not found: {file_path}") + + with open(file_path, "r", encoding="utf-8") as file: + try: + card_json = json.load(file) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {file_path}: {e}") + + return card_json + + +def update_card_data(card_json, data_map): + """ + Replace placeholders in the Adaptive Card JSON with actual values. + Placeholders should be in the form {{key}} in the JSON template. + """ + card_str = json.dumps(card_json) # Convert to string for replacement + for key, value in data_map.items(): + placeholder = f"{{{{{key}}}}}" # e.g., {{name}} + card_str = card_str.replace(placeholder, str(value)) + + return json.loads(card_str) # Convert back to dict diff --git a/test_samples/agentic-test/src/main.py b/test_samples/agentic-test/src/main.py index 085b3385..e5891a9a 100644 --- a/test_samples/agentic-test/src/main.py +++ b/test_samples/agentic-test/src/main.py @@ -16,8 +16,8 @@ ms_agents_logger.setLevel(logging.DEBUG) -from .agent import AGENT_APP, CONNECTION_MANAGER -from .start_server import start_server +from agent import AGENT_APP, CONNECTION_MANAGER +from start_server import start_server start_server( agent_application=AGENT_APP, diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 4368c63e..e9e5f8ec 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -3,6 +3,10 @@ from microsoft_agents.authentication.msal import MsalAuth from microsoft_agents.hosting.core import Connections +from microsoft_agents.hosting.core.authorization import ( + AgentAuthConfiguration, + AuthTypes, +) from tests._common.testing_objects import MockMsalAuth @@ -63,6 +67,152 @@ async def test_acquire_token_on_behalf_of_confidential(self, mocker): ) +class TestMsalAuthTenantResolution: + """ + Test suite for testing tenant resolution methods in MsalAuth. + These methods are critical for multi-tenant authentication support. + """ + + def test_resolve_tenant_id_with_override_parameter(self): + """Test that tenant_id parameter takes precedence when provided""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc" + ) + result = MsalAuth._resolve_tenant_id(config, "tenant-override") + assert result == "tenant-override" + + def test_resolve_tenant_id_with_common_and_tenant_parameter(self): + """Test that tenant_id parameter is used when config.TENANT_ID is 'common'""" + config = AgentAuthConfiguration(tenant_id="common") + result = MsalAuth._resolve_tenant_id(config, "specific-tenant") + assert result == "specific-tenant" + + def test_resolve_tenant_id_with_common_no_tenant_parameter(self): + """Test that None is returned when config.TENANT_ID is 'common' and no tenant_id provided""" + config = AgentAuthConfiguration(tenant_id="common") + result = MsalAuth._resolve_tenant_id(config, None) + assert result is None + + def test_resolve_tenant_id_with_specific_tenant(self): + """Test that config.TENANT_ID is returned when it's a specific value""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc" + ) + result = MsalAuth._resolve_tenant_id(config, None) + assert result == "12345678-1234-1234-1234-123456789abc" + + def test_resolve_tenant_id_no_config_tenant_with_parameter(self): + """Test that tenant_id parameter is used when config.TENANT_ID is not set. + Note: tenant_id can be any string, not just GUID format.""" + config = AgentAuthConfiguration() + result = MsalAuth._resolve_tenant_id(config, "fallback-tenant") + assert result == "fallback-tenant" + + def test_resolve_tenant_id_no_config_tenant_no_parameter(self): + """Test that ValueError is raised when neither config.TENANT_ID nor tenant_id are set""" + config = AgentAuthConfiguration() + with pytest.raises( + ValueError, match="TENANT_ID is not set in the configuration" + ): + MsalAuth._resolve_tenant_id(config, None) + + def test_resolve_authority_with_common_replacement(self): + """Test that /common is replaced with the resolved tenant_id in authority URL""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc", + authority="https://login.microsoftonline.com/common", + ) + result = MsalAuth._resolve_authority(config, None) + assert ( + result + == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc" + ) + + def test_resolve_authority_with_tenant_guid_replacement(self): + """Test that existing tenant GUID is replaced with new tenant_id in authority URL""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc", + authority="https://login.microsoftonline.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + result = MsalAuth._resolve_authority( + config, "new-tenant-11111111-2222-3333-4444-555555555555" + ) + assert ( + result + == "https://login.microsoftonline.com/new-tenant-11111111-2222-3333-4444-555555555555" + ) + + def test_resolve_authority_with_common_and_tenant_parameter(self): + """Test that /common is replaced with provided tenant_id parameter""" + config = AgentAuthConfiguration( + tenant_id="common", authority="https://login.microsoftonline.com/common" + ) + result = MsalAuth._resolve_authority( + config, "override-22222222-3333-4444-5555-666666666666" + ) + assert ( + result + == "https://login.microsoftonline.com/override-22222222-3333-4444-5555-666666666666" + ) + + def test_resolve_authority_no_authority_configured(self): + """Test fallback to default URL when no authority is configured""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc" + ) + result = MsalAuth._resolve_authority(config, None) + assert ( + result + == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc" + ) + + def test_resolve_authority_no_authority_with_tenant_override(self): + """Test fallback to default URL with tenant override when no authority is configured""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc" + ) + result = MsalAuth._resolve_authority( + config, "override-99999999-8888-7777-6666-555555555555" + ) + assert ( + result + == "https://login.microsoftonline.com/override-99999999-8888-7777-6666-555555555555" + ) + + def test_resolve_authority_with_common_no_tenant_parameter(self): + """Test behavior when config.TENANT_ID is 'common' and no tenant_id parameter""" + config = AgentAuthConfiguration( + tenant_id="common", authority="https://login.microsoftonline.com/common" + ) + # When tenant_id is None after resolution, should return original authority + result = MsalAuth._resolve_authority(config, None) + assert result == "https://login.microsoftonline.com/common" + + def test_resolve_authority_regex_with_trailing_slash(self): + """Test that regex correctly handles authority URLs with trailing slashes""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc", + authority="https://login.microsoftonline.com/common/", + ) + result = MsalAuth._resolve_authority(config, None) + assert ( + result + == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/" + ) + + def test_resolve_authority_regex_preserves_path(self): + """Test that regex correctly replaces tenant while preserving additional path segments""" + config = AgentAuthConfiguration( + tenant_id="12345678-1234-1234-1234-123456789abc", + authority="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + ) + result = MsalAuth._resolve_authority(config, None) + assert ( + result + == "https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/oauth2/v2.0/authorize" + ) + + # class TestMsalAuthAgentic: # @pytest.mark.asyncio