From 9502863b9c109941b70572d6142568b777a9add7 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 13 Mar 2026 18:42:02 -0700 Subject: [PATCH] Route BotBuilder auth through HttpServer.handle_request Make HttpServer.handle_request public so plugins can route through SDK-level JWT validation. Change DI from HttpServerAdapter to HttpServer. BotBuilderPlugin now calls handle_request() after CloudAdapter auth. Remove pass-through methods from HttpServer. Co-Authored-By: Claude Opus 4.6 --- .../src/microsoft_teams/a2a/server/plugin.py | 9 +-- packages/apps/src/microsoft_teams/apps/app.py | 10 ++-- .../microsoft_teams/apps/http/http_server.py | 26 ++------- packages/apps/tests/test_app.py | 12 ++-- packages/apps/tests/test_http_server.py | 32 +---------- .../botbuilder/botbuilder_plugin.py | 57 ++++--------------- .../tests/test_botbuilder_plugin.py | 32 +++++++---- .../mcpplugin/server_plugin.py | 11 ++-- 8 files changed, 60 insertions(+), 129 deletions(-) diff --git a/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py b/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py index b81b8d94..74abc765 100644 --- a/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py +++ b/packages/a2aprotocol/src/microsoft_teams/a2a/server/plugin.py @@ -10,7 +10,7 @@ DependencyMetadata, EventMetadata, FastAPIAdapter, - HttpServerAdapter, + HttpServer, LoggerDependencyOptions, Plugin, PluginBase, @@ -31,7 +31,7 @@ @Plugin(name="a2a", version="0.3.7", description="A2A Server Plugin") class A2APlugin(PluginBase): logger: Annotated[Logger, LoggerDependencyOptions()] - http_server_adapter: Annotated[HttpServerAdapter, DependencyMetadata()] + http_server: Annotated[HttpServer, DependencyMetadata()] emit: Annotated[Callable[[str, A2AMessageEvent], Awaitable[None]], EventMetadata(name="custom")] @@ -77,9 +77,10 @@ async def on_init(self) -> None: self.logger.info(f"A2A agent set up at {self.agent_card_path}") self.logger.info(f"A2A agent listening at {self.path}") - if not isinstance(self.http_server_adapter, FastAPIAdapter): + adapter = self.http_server.adapter + if not isinstance(adapter, FastAPIAdapter): raise RuntimeError("A2APlugin requires FastAPIAdapter. Custom adapters are not supported.") - self.http_server_adapter.app.mount(self.path, self.app) + adapter.app.mount(self.path, self.app) def _setup_executor(self) -> AgentExecutor: return CustomAgentExecutor(self.emit) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 6fc89208..75b7fe0f 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -114,7 +114,7 @@ def __init__(self, **options: Unpack[AppOptions]): # Create HttpServer (not a plugin — owned directly by App) adapter = self.options.http_server_adapter or FastAPIAdapter() self.server = HttpServer(adapter, self.log) - self.container.set_provider("HttpServerAdapter", providers.Object(self.server.adapter)) + self.container.set_provider("HttpServer", providers.Object(self.server)) self._port: Optional[int] = None self._initialized = False @@ -248,7 +248,7 @@ async def start(self, port: Optional[int] = None) -> None: self.log.info("Teams app started successfully") self._events.emit("start", StartEvent(port=self._port)) - tasks.append(self.server.start(self._port)) + tasks.append(self.server.adapter.start(self._port)) await asyncio.gather(*tasks) except (asyncio.CancelledError, KeyboardInterrupt): @@ -268,7 +268,7 @@ async def stop(self) -> None: """Stop the Teams application.""" try: # Stop HTTP server first - await self.server.stop() + await self.server.adapter.stop() # Stop all plugins for plugin in reversed(self.plugins): @@ -463,7 +463,7 @@ def page(self, name: str, dir_path: str, page_path: Optional[str] = None) -> Non app.page("customform", os.path.join(os.path.dirname(__file__), "views", "customform"), "/tabs/dialog-form") ``` """ - self.server.serve_static(page_path or f"/{name}", dir_path) + self.server.adapter.serve_static(page_path or f"/{name}", dir_path) def tab(self, name: str, path: str) -> None: """ @@ -517,7 +517,7 @@ async def handler(request: HttpRequest) -> HttpResponse: result = await func(ctx) return HttpResponse(status=200, body=result) - self.server.register_route("POST", f"/api/functions/{endpoint_name}", handler) + self.server.adapter.register_route("POST", f"/api/functions/{endpoint_name}", handler) return func # Direct decoration: @app.func diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py index b99c5843..5f0a9678 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -13,7 +13,7 @@ from ..auth import TokenValidator from ..events import ActivityEvent, CoreActivity -from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, HttpServerAdapter +from .adapter import HttpRequest, HttpResponse, HttpServerAdapter class HttpServer: @@ -68,11 +68,11 @@ def initialize( self._token_validator = TokenValidator.for_service(app_id, self._logger) self._logger.debug("JWT validation enabled for /api/messages") - self._adapter.register_route("POST", "/api/messages", self._handle_activity) + self._adapter.register_route("POST", "/api/messages", self.handle_request) self._initialized = True - async def _handle_activity(self, request: HttpRequest) -> HttpResponse: - """Handle incoming activity on POST /api/messages.""" + async def handle_request(self, request: HttpRequest) -> HttpResponse: + """Handle incoming activity request. Public so plugins (e.g. BotBuilder) can route through SDK auth.""" try: body = request["body"] headers = request["headers"] @@ -151,21 +151,3 @@ def _format_response(self, result: Any) -> HttpResponse: if body is not None: return HttpResponse(status=status_code, body=body) return HttpResponse(status=status_code, body=None) - - def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: - """Delegate route registration to the adapter.""" - self._adapter.register_route(method, path, handler) - - def serve_static(self, path: str, directory: str) -> None: - """Delegate static file serving to the adapter.""" - self._adapter.serve_static(path, directory) - - async def start(self, port: int) -> None: - """Start the HTTP server. Blocks until stopped.""" - self._logger.info(f"Starting HTTP server on port {port}") - await self._adapter.start(port) - - async def stop(self) -> None: - """Stop the HTTP server.""" - self._logger.info("Stopping HTTP server") - await self._adapter.stop() diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index 1b0e8af0..411abbde 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -101,8 +101,8 @@ def basic_options(self, mock_logger, mock_storage): def _mock_http_server(self, app: App) -> App: """Helper to mock the HTTP server methods.""" - app.server.start = AsyncMock() # type: ignore[method-assign] - app.server.stop = AsyncMock() # type: ignore[method-assign] + app.server.adapter.start = AsyncMock() # type: ignore[method-assign] + app.server.adapter.stop = AsyncMock() # type: ignore[method-assign] return app @pytest.fixture(scope="function") @@ -136,7 +136,7 @@ def test_app_starts_successfully(self, basic_options): async def test_app_lifecycle_start_stop(self, app_with_options): """Test basic app lifecycle: start and stop.""" - # Test start — server.start is already mocked by _mock_http_server + # Test start — server.adapter.start is already mocked by _mock_http_server start_task = asyncio.create_task(app_with_options.start(3978)) await asyncio.sleep(0.1) @@ -182,8 +182,8 @@ async def on_stop(self) -> None: # noqa: D102 async def blocking_start(port): await block.wait() - app.server.start = AsyncMock(side_effect=blocking_start) # type: ignore[method-assign] - app.server.stop = AsyncMock() # type: ignore[method-assign] + app.server.adapter.start = AsyncMock(side_effect=blocking_start) # type: ignore[method-assign] + app.server.adapter.stop = AsyncMock() # type: ignore[method-assign] start_task = asyncio.create_task(app.start(3978)) await asyncio.sleep(0.1) @@ -522,7 +522,7 @@ async def logging_middleware(ctx: ActivityContext) -> None: async def test_func_decorator_registration(self, app_with_options: App): """Simple test that @app.func registers a function.""" mock_register = MagicMock() - app_with_options.server.register_route = mock_register # type: ignore[method-assign] + app_with_options.server.adapter.register_route = mock_register # type: ignore[method-assign] async def dummy_func(ctx): return "called" diff --git a/packages/apps/tests/test_http_server.py b/packages/apps/tests/test_http_server.py index 4ecc3830..72a56c44 100644 --- a/packages/apps/tests/test_http_server.py +++ b/packages/apps/tests/test_http_server.py @@ -82,7 +82,7 @@ async def mock_handler(event): headers={}, ) - result = await server._handle_activity(request) + result = await server.handle_request(request) assert result["status"] == 200 assert result["body"] == expected_body @@ -102,7 +102,7 @@ async def failing_handler(event): headers={}, ) - result = await server._handle_activity(request) + result = await server.handle_request(request) assert result["status"] == 500 mock_logger.exception.assert_called_once() @@ -117,37 +117,11 @@ async def test_handle_activity_no_handler(self, server, mock_logger): headers={}, ) - result = await server._handle_activity(request) + result = await server.handle_request(request) assert result["status"] == 500 mock_logger.warning.assert_called() - @pytest.mark.asyncio - async def test_start(self, server, mock_adapter): - """Test server start delegates to adapter.""" - await server.start(3978) - mock_adapter.start.assert_called_once_with(3978) - - @pytest.mark.asyncio - async def test_stop(self, server, mock_adapter): - """Test server stop delegates to adapter.""" - await server.stop() - mock_adapter.stop.assert_called_once() - - def test_register_route_delegates(self, server, mock_adapter): - """Test register_route delegates to adapter.""" - - async def handler(req): - return HttpResponse(status=200, body=None) - - server.register_route("POST", "/custom", handler) - mock_adapter.register_route.assert_called_once_with("POST", "/custom", handler) - - def test_serve_static_delegates(self, server, mock_adapter): - """Test serve_static delegates to adapter.""" - server.serve_static("/static", "/path/to/dir") - mock_adapter.serve_static.assert_called_once_with("/static", "/path/to/dir") - class TestFastAPIAdapter: """Test cases for FastAPIAdapter.""" diff --git a/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py b/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py index 70e8fa6e..bc7686d5 100644 --- a/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py +++ b/packages/botbuilder/src/microsoft_teams/botbuilder/botbuilder_plugin.py @@ -6,18 +6,16 @@ import importlib.metadata from logging import Logger from types import SimpleNamespace -from typing import Annotated, Any, Callable, Dict, Optional, TypedDict, Unpack, cast +from typing import Annotated, Optional, TypedDict, Unpack, cast -from microsoft_teams.api import Credentials, InvokeResponse +from microsoft_teams.api import Credentials from microsoft_teams.apps import ( DependencyMetadata, - EventMetadata, - HttpServerAdapter, + HttpServer, LoggerDependencyOptions, Plugin, PluginBase, ) -from microsoft_teams.apps.events import ActivityEvent, CoreActivity, ErrorEvent from microsoft_teams.apps.http import HttpRequest, HttpResponse from botbuilder.core import ( @@ -53,10 +51,7 @@ class BotBuilderPlugin(PluginBase): # Dependency injections logger: Annotated[Logger, LoggerDependencyOptions()] credentials: Annotated[Optional[Credentials], DependencyMetadata(optional=True)] - http_server_adapter: Annotated[HttpServerAdapter, DependencyMetadata()] - - on_error_event: Annotated[Callable[[ErrorEvent], None], EventMetadata(name="error")] - on_activity_event: Annotated[Callable[[ActivityEvent], InvokeResponse[Any]], EventMetadata(name="activity")] + http_server: Annotated[HttpServer, DependencyMetadata()] def __init__(self, **options: Unpack[BotBuilderPluginOptions]): """ @@ -94,14 +89,15 @@ async def on_init(self) -> None: self.logger.debug("BotBuilder plugin initialized successfully") - # Register the activity route via adapter - self.http_server_adapter.register_route("POST", "/api/messages", self._handle_activity) + # Register the activity route via adapter (bypasses HttpServer's default /api/messages) + self.http_server.adapter.register_route("POST", "/api/messages", self._handle_activity) async def _handle_activity(self, request: HttpRequest) -> HttpResponse: """ - Pure handler for POST /api/messages. + Handler for POST /api/messages. - Processes via Bot Framework, then passes to the Teams pipeline. + Runs Bot Framework CloudAdapter auth + handler first, + then routes through HttpServer.handle_request for SDK-level JWT validation and pipeline. """ if not self.adapter: raise RuntimeError("plugin not registered") @@ -127,39 +123,8 @@ async def logic(turn_context: TurnContext) -> None: auth_header = headers.get("authorization") or headers.get("Authorization") or "" await self.adapter.process_activity(auth_header, activity_bf, logic) - # Process through Teams pipeline - core_activity = CoreActivity.model_validate(body) - token = cast( - Any, - SimpleNamespace( - app_id="", - app_display_name="", - tenant_id="", - service_url=core_activity.service_url or "", - from_="azure", - from_id="", - is_expired=lambda: False, - ), - ) - - event_result = self.on_activity_event(ActivityEvent(body=core_activity, token=token)) - result: Any = await cast(Any, event_result) - - # Format response - status_code: int = 200 - resp_body: Any = None - resp_dict: Dict[str, Any] = {} - if result is not None and hasattr(result, "model_dump"): - resp_dict = cast(Dict[str, Any], result.model_dump(exclude_none=True)) - elif isinstance(result, dict): - resp_dict = cast(Dict[str, Any], result) - - if "status" in resp_dict: - status_code = int(resp_dict.get("status", 200)) - if "body" in resp_dict: - resp_body = resp_dict.get("body") - - return HttpResponse(status=status_code, body=resp_body) + # Route through HttpServer for SDK auth + Teams pipeline + return await self.http_server.handle_request(request) except Exception as err: self.logger.error(f"Error processing activity: {err}", exc_info=True) diff --git a/packages/botbuilder/tests/test_botbuilder_plugin.py b/packages/botbuilder/tests/test_botbuilder_plugin.py index 01907578..25b8e4ce 100644 --- a/packages/botbuilder/tests/test_botbuilder_plugin.py +++ b/packages/botbuilder/tests/test_botbuilder_plugin.py @@ -10,8 +10,9 @@ from botbuilder.core import ActivityHandler, TurnContext from botbuilder.integration.aiohttp import CloudAdapter from botbuilder.schema import Activity -from microsoft_teams.api import Credentials, InvokeResponse -from microsoft_teams.apps.http.adapter import HttpRequest, HttpServerAdapter +from microsoft_teams.api import Credentials +from microsoft_teams.apps.http import HttpRequest, HttpResponse +from microsoft_teams.apps.http.http_server import HttpServer from microsoft_teams.botbuilder import BotBuilderPlugin @@ -23,27 +24,31 @@ def mock_logger(self): return MagicMock() @pytest.fixture - def plugin_without_adapter(self): + def mock_http_server(self): + server = MagicMock(spec=HttpServer) + server.adapter = MagicMock() + server.handle_request = AsyncMock(return_value=HttpResponse(status=200, body=None)) + return server + + @pytest.fixture + def plugin_without_adapter(self, mock_http_server): plugin = BotBuilderPlugin() plugin.credentials = MagicMock(spec=Credentials) plugin.credentials.client_id = "abc" plugin.credentials.client_secret = "secret" plugin.credentials.tenant_id = "tenant-123" - plugin.http_server_adapter = MagicMock(spec=HttpServerAdapter) + plugin.http_server = mock_http_server plugin.logger = MagicMock() return plugin @pytest.fixture - def plugin_with_adapter(self) -> BotBuilderPlugin: + def plugin_with_adapter(self, mock_http_server) -> BotBuilderPlugin: adapter = MagicMock(spec=CloudAdapter) plugin = BotBuilderPlugin(adapter=adapter) handler = AsyncMock(spec=ActivityHandler) plugin.handler = handler - plugin.http_server_adapter = MagicMock(spec=HttpServerAdapter) + plugin.http_server = mock_http_server plugin.logger = MagicMock() - - # Set up the on_activity_event handler - plugin.on_activity_event = AsyncMock(return_value=InvokeResponse(status=200)) return plugin @pytest.mark.asyncio @@ -63,9 +68,9 @@ async def test_on_init_creates_adapter_when_missing(self, plugin_without_adapter mock_adapter_class.assert_called_once() assert plugin_without_adapter.adapter == "mock_adapter" - # Should have registered route via adapter - plugin_without_adapter.http_server_adapter.register_route.assert_called_once() - call_args = plugin_without_adapter.http_server_adapter.register_route.call_args + # Should have registered route via http_server.adapter + plugin_without_adapter.http_server.adapter.register_route.assert_called_once() + call_args = plugin_without_adapter.http_server.adapter.register_route.call_args assert call_args[0][0] == "POST" assert call_args[0][1] == "/api/messages" @@ -104,6 +109,9 @@ async def fake_process_activity(auth_header, activity, logic): # Ensure handler called via TurnContext plugin_with_adapter.handler.on_turn.assert_awaited() + # Should have routed through HttpServer.handle_request + plugin_with_adapter.http_server.handle_request.assert_awaited_once_with(request) + # Should return a valid HttpResponse assert result["status"] == 200 diff --git a/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py b/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py index 1417b8d0..bd610a6e 100644 --- a/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py +++ b/packages/mcpplugin/src/microsoft_teams/mcpplugin/server_plugin.py @@ -14,7 +14,7 @@ from microsoft_teams.apps import ( DependencyMetadata, FastAPIAdapter, - HttpServerAdapter, + HttpServer, Plugin, PluginBase, PluginStartEvent, @@ -41,7 +41,7 @@ class McpServerPlugin(PluginBase): """ # Dependency injection - http_server_adapter: Annotated[HttpServerAdapter, DependencyMetadata()] + http_server: Annotated[HttpServer, DependencyMetadata()] def __init__(self, name: str = "teams-mcp-server", path: str = "/mcp", logger: logging.Logger | None = None): """ @@ -159,13 +159,14 @@ async def on_start(self, event: PluginStartEvent) -> None: return try: - if not isinstance(self.http_server_adapter, FastAPIAdapter): + adapter = self.http_server.adapter + if not isinstance(adapter, FastAPIAdapter): raise RuntimeError("McpServerPlugin requires FastAPIAdapter. Custom adapters are not supported.") # We mount the mcp server as a separate app at self.path mcp_http_app = self.mcp_server.http_app(path=self.path, transport="http") - self.http_server_adapter.lifespans.append(mcp_http_app.lifespan) # pyright: ignore[reportArgumentType] - self.http_server_adapter.app.mount("/", mcp_http_app) + adapter.lifespans.append(mcp_http_app.lifespan) # pyright: ignore[reportArgumentType] + adapter.app.mount("/", mcp_http_app) self._mounted = True