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..a3ce62de45c 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,27 +20,17 @@ 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="..." -``` - -## Installing Dependencies - -From the repository root or the sample folder: - -```bash -uv sync +# 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" ``` ## 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`. @@ -52,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. @@ -83,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. @@ -92,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 e4e77a3d78c..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 @@ -1,17 +1,17 @@ # /// 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" # ] # /// # 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.aiohttp import CloudAdapter, jwt_authorization_decorator, 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. @@ -163,30 +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: - request["claims_identity"] = ClaimsIdentity( - { - AuthenticationConstants.AUDIENCE_CLAIM: "anonymous", - AuthenticationConstants.APP_ID_CLAIM: "anonymous-app", - }, - False, - "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: @@ -197,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(middleware=[middleware_fn]) + app = web.Application(middlewares=[anonymous_claims_middleware] if config.use_anonymous_mode else []) storage = MemoryStorage() agent = build_agent() @@ -224,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