From df18abea287e0661a404d6b6dc988c3d65772d6c Mon Sep 17 00:00:00 2001 From: aifanatic Date: Thu, 22 Oct 2026 07:38:44 -0500 Subject: [PATCH 1/3] Python: Fix M365 agent sample startup with current SDKs - aiohttp's Application takes middlewares=, not middleware= (TypeError at startup). - FoundryChatClient needs a credential; pass AzureCliCredential and document the FOUNDRY_* settings the sample actually reads instead of OpenAI ones. - The M365 Agents SDK now requires a connection manager for CloudAdapter and AgentApplication; give anonymous mode an empty service connection. - ClaimsIdentity's signature is now (claims, authentication_type, ...); build a claim-less anonymous identity so the adapter skips token acquisition. Co-Authored-By: Claude Opus 5.5 --- .../05-end-to-end/m365-agent/README.md | 9 ++-- .../m365-agent/m365_agent_demo/app.py | 53 +++++++++---------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/python/samples/05-end-to-end/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md index ec9da47331c..891eac8ff8e 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -8,7 +8,8 @@ This sample demonstrates a simple Weather Forecast Agent built with the Python M - [uv](https://github.com/astral-sh/uv) for fast dependency management - [devtunnel](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started?tabs=windows) - `agentsplayground` for playground/testing -- Access to OpenAI or Azure OpenAI with a model like `gpt-4o-mini` +- A Microsoft Foundry project with a deployed model like `gpt-4o-mini` +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli), signed in with `az login` ## Configuration @@ -19,9 +20,9 @@ Set the following environment variables: export PORT=3978 export USE_ANONYMOUS_MODE=True # set to false if using auth -# OpenAI -export OPENAI_API_KEY="..." -export OPENAI_CHAT_COMPLETION_MODEL="..." +# Microsoft Foundry (authenticates with your `az login` session) +export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" +export FOUNDRY_MODEL="gpt-4o-mini" ``` ## Installing Dependencies diff --git a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py index e4e77a3d78c..fa459ad9ff8 100644 --- a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py +++ b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py @@ -11,7 +11,7 @@ # /// # Copyright (c) Microsoft. All rights reserved. # Run with any PEP 723 compatible runner, e.g.: -# uv run samples/demos/m365-agent/m365_agent_demo/app.py +# uv run samples/05-end-to-end/m365-agent/m365_agent_demo/app.py import os from dataclasses import dataclass @@ -22,13 +22,14 @@ from agent_framework.foundry import FoundryChatClient from aiohttp import web from aiohttp.web_middlewares import middleware +from azure.identity import AzureCliCredential from dotenv import load_dotenv from microsoft_agents.activity import load_configuration_from_env from microsoft_agents.authentication.msal import MsalConnectionManager from microsoft_agents.hosting.aiohttp import CloudAdapter, start_agent_process from microsoft_agents.hosting.core import ( AgentApplication, - AuthenticationConstants, + AgentAuthConfiguration, Authorization, ClaimsIdentity, MemoryStorage, @@ -55,7 +56,7 @@ - Supports both anonymous and authenticated scenarios. - Uses aiohttp for web hosting. -To run, set the appropriate environment variables (check .env.example file) for authentication or use +To run, set the environment variables listed in the README for authentication or use anonymous mode for local testing. """ @@ -101,51 +102,53 @@ def get_weather( def build_agent() -> Agent: """Create and return the chat agent instance with weather tool registered.""" - _client = FoundryChatClient() + # Reads FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL from the environment; run `az login` first. + _client = FoundryChatClient(credential=AzureCliCredential()) return Agent( client=_client, name="WeatherAgent", instructions="You are a helpful weather agent.", tools=get_weather ) -def build_connection_manager(config: AppConfig) -> MsalConnectionManager | None: - """Build the connection manager unless running in anonymous mode. +def build_connection_manager(config: AppConfig) -> MsalConnectionManager: + """Build the connection manager. Args: config: Application configuration. Returns: - MsalConnectionManager | None: Connection manager when authenticated mode is enabled. + MsalConnectionManager: Connection manager configured from the environment, or an empty + placeholder connection in anonymous mode. """ if config.use_anonymous_mode: - return None + # CloudAdapter requires a connection manager, but anonymous requests never request tokens, + # so an empty service connection is enough for local testing. + return MsalConnectionManager(connections_configurations={"SERVICE_CONNECTION": AgentAuthConfiguration()}) return MsalConnectionManager(**config.agents_sdk_config) -def build_adapter(connection_manager: MsalConnectionManager | None) -> CloudAdapter: - """Instantiate the CloudAdapter with the optional connection manager.""" +def build_adapter(connection_manager: MsalConnectionManager) -> CloudAdapter: + """Instantiate the CloudAdapter with the connection manager.""" return CloudAdapter(connection_manager=connection_manager) def build_authorization( - storage: MemoryStorage, connection_manager: MsalConnectionManager | None, config: AppConfig -) -> Authorization | None: - """Create Authorization component if not in anonymous mode. + storage: MemoryStorage, connection_manager: MsalConnectionManager, config: AppConfig +) -> Authorization: + """Create the Authorization component. Args: storage: State storage backend. - connection_manager: Optional connection manager. + connection_manager: Connection manager. config: Application configuration. Returns: - Authorization | None: Authorization component when enabled. + Authorization: Authorization component. """ - if config.use_anonymous_mode: - return None return Authorization(storage, connection_manager, **config.agents_sdk_config) def build_agent_application( storage: MemoryStorage, adapter: CloudAdapter, - authorization: Authorization | None, + authorization: Authorization, config: AppConfig, ) -> AgentApplication[TurnState]: """Compose and return the AgentApplication instance. @@ -153,7 +156,7 @@ def build_agent_application( Args: storage: Storage implementation. adapter: CloudAdapter handling requests. - authorization: Optional authorization component. + authorization: Authorization component. config: App configuration. Returns: AgentApplication[TurnState]: Configured agent application. @@ -176,14 +179,8 @@ def build_anonymous_claims_middleware(use_anonymous_mode: bool): async def anonymous_claims_middleware(request, handler): """Inject claims for anonymous users if anonymous mode is active.""" if use_anonymous_mode: - request["claims_identity"] = ClaimsIdentity( - { - AuthenticationConstants.AUDIENCE_CLAIM: "anonymous", - AuthenticationConstants.APP_ID_CLAIM: "anonymous-app", - }, - False, - "Anonymous", - ) + # An anonymous identity with no claims makes the adapter skip token acquisition. + request["claims_identity"] = ClaimsIdentity(authentication_type="Anonymous") return await handler(request) return anonymous_claims_middleware @@ -198,7 +195,7 @@ def create_app(config: AppConfig) -> web.Application: web.Application: Fully initialized web application. """ middleware_fn = build_anonymous_claims_middleware(config.use_anonymous_mode) - app = web.Application(middleware=[middleware_fn]) + app = web.Application(middlewares=[middleware_fn]) storage = MemoryStorage() agent = build_agent() From b6fcdf3a880ac6429b78929fef2ff9bbf141b1c2 Mon Sep 17 00:00:00 2001 From: aifanatic Date: Mon, 26 Oct 2026 13:21:53 -0500 Subject: [PATCH 2/3] Python: Require M365 Agents SDK 1.4+ in M365 sample The anonymous-mode identity uses ClaimsIdentity(authentication_type=...), which arrived in microsoft-agents-hosting-core 1.4.0; on 1.0-1.3 the constructor requires claims and is_authenticated. Verified the sample's anonymous message flow on 1.4.0 through 1.10.0.dev1. Co-Authored-By: Claude Opus 5.5 --- .../05-end-to-end/m365-agent/m365_agent_demo/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py index fa459ad9ff8..75d49e1c3e3 100644 --- a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py +++ b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py @@ -1,10 +1,10 @@ # /// script # requires-python = ">=3.11" # dependencies = [ -# "microsoft-agents-hosting-aiohttp", -# "microsoft-agents-hosting-core", -# "microsoft-agents-authentication-msal", -# "microsoft-agents-activity", +# "microsoft-agents-hosting-aiohttp>=1.4.0", +# "microsoft-agents-hosting-core>=1.4.0", +# "microsoft-agents-authentication-msal>=1.4.0", +# "microsoft-agents-activity>=1.4.0", # "agent-framework-foundry", # "aiohttp" # ] From 3a4cf647f858df3ae0b54c5ba712189e04c9236f Mon Sep 17 00:00:00 2001 From: aifanatic Date: Tue, 27 Oct 2026 07:22:48 -0500 Subject: [PATCH 3/3] samples: validate bearer tokens in the M365 sample's authenticated mode With USE_ANONYMOUS_MODE=false the sample installed no JWT validation, so requests with a missing or invalid token still reached the agent. Wrap /api/messages with the SDK's jwt_authorization_decorator and its default connection configuration, keep the anonymous middleware for anonymous mode, and document running the PEP 723 script with uv run. Co-Authored-By: Claude Opus 5.5 --- .../05-end-to-end/m365-agent/README.md | 22 +-- .../m365-agent/m365_agent_demo/app.py | 35 ++-- .../end_to_end/test_m365_agent_sample.py | 169 ++++++++++++++++++ 3 files changed, 189 insertions(+), 37 deletions(-) create mode 100644 python/tests/samples/end_to_end/test_m365_agent_sample.py diff --git a/python/samples/05-end-to-end/m365-agent/README.md b/python/samples/05-end-to-end/m365-agent/README.md index 891eac8ff8e..a3ce62de45c 100644 --- a/python/samples/05-end-to-end/m365-agent/README.md +++ b/python/samples/05-end-to-end/m365-agent/README.md @@ -25,22 +25,12 @@ export FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/a export FOUNDRY_MODEL="gpt-4o-mini" ``` -## Installing Dependencies - -From the repository root or the sample folder: - -```bash -uv sync -``` - ## Running the Agent Locally -```bash -# Activate environment first if not already -source .venv/bin/activate # (Windows PowerShell: .venv\Scripts\Activate.ps1) +`app.py` declares its dependencies (the M365 Agents SDK hosting and authentication packages, `agent-framework-foundry`, and `aiohttp`) in an inline [PEP 723](https://peps.python.org/pep-0723/) header. `uv run` installs them into an isolated environment the first time you run it. Run it from this sample folder: -# Run the weather agent demo -python m365_agent_demo/app.py +```bash +uv run m365_agent_demo/app.py ``` The agent starts on `http://localhost:3978`. Health check: `GET /api/health`. @@ -53,7 +43,7 @@ The agent starts on `http://localhost:3978`. Health check: `GET /api/health`. winget install agentsplayground ``` -2. Start the Python agent locally: `python m365_agent_demo/app.py` +2. Start the Python agent locally: `uv run m365_agent_demo/app.py` 3. Start the playground: `agentsplayground` 4. Chat with the Weather Agent. @@ -84,7 +74,7 @@ To test via WebChat you can provision an Azure Bot and point its messaging endpo ``` 4. Set the bot Messaging endpoint to: `https:///api/messages` -5. Run your local agent: `python m365_agent_demo/app.py` +5. Run your local agent: `uv run m365_agent_demo/app.py` 6. Use "Test in WebChat" in Azure Portal. > Federated Credentials or Managed Identity auth types typically require deployment to Azure App Service instead of tunneling. @@ -93,7 +83,7 @@ To test via WebChat you can provision an Azure Bot and point its messaging endpo - 404 on `/api/messages`: Ensure you are POSTing and using the correct tunnel URL. - Empty responses: Check model key / quota and ensure environment variables are set. -- Auth errors when anonymous disabled: Validate MSAL config matches your Azure Bot registration. +- 401 responses when anonymous mode is disabled: requests to `/api/messages` must carry a bearer token issued for your Azure Bot. Validate that the MSAL config matches your Azure Bot registration. ## Further Reading diff --git a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py index 75d49e1c3e3..8fb202dad1d 100644 --- a/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py +++ b/python/samples/05-end-to-end/m365-agent/m365_agent_demo/app.py @@ -26,7 +26,7 @@ from dotenv import load_dotenv from microsoft_agents.activity import load_configuration_from_env from microsoft_agents.authentication.msal import MsalConnectionManager -from microsoft_agents.hosting.aiohttp import CloudAdapter, start_agent_process +from microsoft_agents.hosting.aiohttp import CloudAdapter, jwt_authorization_decorator, start_agent_process from microsoft_agents.hosting.core import ( AgentApplication, AgentAuthConfiguration, @@ -166,24 +166,12 @@ def build_agent_application( ) -def build_anonymous_claims_middleware(use_anonymous_mode: bool): - """Return a middleware that injects anonymous claims when enabled. - - Args: - use_anonymous_mode: Whether to apply anonymous identity for each request. - Returns: - Callable: Aiohttp middleware function. - """ - - @middleware - async def anonymous_claims_middleware(request, handler): - """Inject claims for anonymous users if anonymous mode is active.""" - if use_anonymous_mode: - # An anonymous identity with no claims makes the adapter skip token acquisition. - request["claims_identity"] = ClaimsIdentity(authentication_type="Anonymous") - return await handler(request) - - return anonymous_claims_middleware +@middleware +async def anonymous_claims_middleware(request, handler): + """Inject an anonymous identity for local testing without authentication.""" + # An anonymous identity with no claims makes the adapter skip token acquisition. + request["claims_identity"] = ClaimsIdentity(authentication_type="Anonymous") + return await handler(request) def create_app(config: AppConfig) -> web.Application: @@ -194,8 +182,7 @@ def create_app(config: AppConfig) -> web.Application: Returns: web.Application: Fully initialized web application. """ - middleware_fn = build_anonymous_claims_middleware(config.use_anonymous_mode) - app = web.Application(middlewares=[middleware_fn]) + app = web.Application(middlewares=[anonymous_claims_middleware] if config.use_anonymous_mode else []) storage = MemoryStorage() agent = build_agent() @@ -221,6 +208,12 @@ async def health(request: web.Request) -> web.Response: async def entry_point(req: web.Request) -> web.Response: return await start_agent_process(req, req.app["agent_app"], req.app["adapter"]) + if not config.use_anonymous_mode: + # Validate the bearer token on every incoming activity; requests with a missing or + # invalid token get a 401 before they reach the agent. + app["agent_configuration"] = connection_manager.get_default_connection_configuration() + entry_point = jwt_authorization_decorator(entry_point) + app.add_routes([ web.get("/api/health", health), web.get("/api/messages", lambda _: web.Response(status=200)), diff --git a/python/tests/samples/end_to_end/test_m365_agent_sample.py b/python/tests/samples/end_to_end/test_m365_agent_sample.py new file mode 100644 index 00000000000..acb00b8ccd0 --- /dev/null +++ b/python/tests/samples/end_to_end/test_m365_agent_sample.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for request authentication in the M365 agent sample. + +The M365 Agents SDK hosting packages come from the sample's PEP 723 header, not the +workspace, so these tests are skipped unless they are installed. +""" + +import importlib.util +import time +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +pytest.importorskip("microsoft_agents.hosting.aiohttp") +pytest.importorskip("microsoft_agents.authentication.msal") + +import jwt # noqa: E402 +from aiohttp import web # noqa: E402 +from aiohttp.test_utils import TestClient, TestServer # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import rsa # noqa: E402 +from microsoft_agents.activity import load_configuration_from_env # noqa: E402 +from microsoft_agents.hosting.core.authorization.jwt import JwtTokenValidator # noqa: E402 + +_APP_PATH = Path(__file__).parents[3] / "samples" / "05-end-to-end" / "m365-agent" / "m365_agent_demo" / "app.py" + +_CLIENT_ID = "11111111-1111-1111-1111-111111111111" +_BOT_FRAMEWORK_ISSUER = "https://api.botframework.com" +_ACTIVITY = {"type": "message", "text": "What's the weather in Seattle?"} + + +def _load_app_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("m365_agent_demo_app", _APP_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _sign(key: rsa.RSAPrivateKey, audience: str) -> str: + now = int(time.time()) + claims = {"aud": audience, "iss": _BOT_FRAMEWORK_ISSUER, "iat": now, "nbf": now, "exp": now + 600} + return jwt.encode(claims, key, algorithm="RS256", headers={"kid": "test-key"}) + + +@pytest.fixture +def trusted_key(monkeypatch: pytest.MonkeyPatch) -> rsa.RSAPrivateKey: + """Serve a local signing key instead of fetching the Bot Framework JWKS.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + signing_key = jwt.PyJWK.from_dict(jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True)) + + async def get_signing_key(_jwks_uri: str, _header: dict[str, Any]) -> jwt.PyJWK: + return signing_key + + monkeypatch.setattr(JwtTokenValidator._jwk_client_manager, "get_signing_key", get_signing_key) + return key + + +@pytest.fixture +def app_module(monkeypatch: pytest.MonkeyPatch) -> tuple[ModuleType, list[Any]]: + """Load the sample with the agent and SDK turn processing replaced by recorders.""" + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://example.services.ai.azure.com/api/projects/test") + monkeypatch.setenv("FOUNDRY_MODEL", "test-model") + module = _load_app_module() + processed: list[Any] = [] + + async def start_agent_process(request: web.Request, _agent_app: Any, _adapter: Any) -> web.Response: + processed.append(request["claims_identity"]) + return web.Response(status=202) + + monkeypatch.setattr(module, "build_agent", lambda: object()) + monkeypatch.setattr(module, "start_agent_process", start_agent_process) + return module, processed + + +def _config(module: ModuleType, *, use_anonymous_mode: bool) -> Any: + env = { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": _CLIENT_ID, + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET": "secret", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": "22222222-2222-2222-2222-222222222222", + } + return module.AppConfig( + use_anonymous_mode=use_anonymous_mode, + port=0, + agents_sdk_config=load_configuration_from_env(env), + ) + + +async def _post_activity(app: web.Application, headers: dict[str, str]) -> int: + async with TestClient(TestServer(app)) as client: + response = await client.post("/api/messages", json=_ACTIVITY, headers=headers) + return response.status + + +@pytest.mark.parametrize( + "headers", + [ + pytest.param({}, id="missing"), + pytest.param({"Authorization": "Basic dXNlcjpwYXNz"}, id="not-bearer"), + pytest.param({"Authorization": "Bearer not-a-jwt"}, id="malformed"), + ], +) +async def test_authenticated_mode_rejects_requests_without_a_valid_token( + app_module: tuple[ModuleType, list[Any]], headers: dict[str, str] +) -> None: + module, processed = app_module + app = module.create_app(_config(module, use_anonymous_mode=False)) + + assert await _post_activity(app, headers) == 401 + assert processed == [] + + +async def test_authenticated_mode_rejects_token_signed_by_untrusted_key( + app_module: tuple[ModuleType, list[Any]], trusted_key: rsa.RSAPrivateKey +) -> None: + module, processed = app_module + app = module.create_app(_config(module, use_anonymous_mode=False)) + untrusted_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + status = await _post_activity(app, {"Authorization": f"Bearer {_sign(untrusted_key, _CLIENT_ID)}"}) + + assert status == 401 + assert processed == [] + + +async def test_authenticated_mode_rejects_token_for_another_audience( + app_module: tuple[ModuleType, list[Any]], trusted_key: rsa.RSAPrivateKey +) -> None: + module, processed = app_module + app = module.create_app(_config(module, use_anonymous_mode=False)) + + status = await _post_activity(app, {"Authorization": f"Bearer {_sign(trusted_key, 'another-app')}"}) + + assert status == 401 + assert processed == [] + + +async def test_authenticated_mode_accepts_valid_token( + app_module: tuple[ModuleType, list[Any]], trusted_key: rsa.RSAPrivateKey +) -> None: + module, processed = app_module + app = module.create_app(_config(module, use_anonymous_mode=False)) + + status = await _post_activity(app, {"Authorization": f"Bearer {_sign(trusted_key, _CLIENT_ID)}"}) + + assert status == 202 + assert [identity.get_claim_value("aud") for identity in processed] == [_CLIENT_ID] + + +async def test_anonymous_mode_accepts_requests_without_a_token(app_module: tuple[ModuleType, list[Any]]) -> None: + module, processed = app_module + app = module.create_app(_config(module, use_anonymous_mode=True)) + + assert await _post_activity(app, {}) == 202 + assert len(processed) == 1 + assert processed[0].allow_anonymous + + +async def test_health_endpoint_does_not_require_a_token(app_module: tuple[ModuleType, list[Any]]) -> None: + module, _ = app_module + app = module.create_app(_config(module, use_anonymous_mode=False)) + + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/health") + + assert response.status == 200