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 8aab280b..28f8f16e 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -77,6 +77,12 @@ def initialize( if app_id and not skip_auth: self._token_validator = TokenValidator.for_service(app_id) logger.debug("JWT validation enabled for %s", self._messaging_endpoint) + elif not skip_auth: + logger.warning( + "No credentials configured and skip_auth is not enabled. " + "All incoming requests will be rejected. Configure client authentication " + "to securely receive messages, or set skip_auth=True for local development." + ) self._adapter.register_route("POST", self._messaging_endpoint, self.handle_request) self._initialized = True @@ -90,24 +96,10 @@ async def handle_request(self, request: HttpRequest) -> HttpResponse: # Validate JWT token authorization = headers.get("authorization") or headers.get("Authorization") or "" - if self._token_validator and not self._skip_auth: - if not authorization.startswith("Bearer "): - return HttpResponse(status=401, body={"error": "Unauthorized"}) - - raw_token = authorization.removeprefix("Bearer ") + if self._skip_auth: + # Auth explicitly skipped — use a default token service_url = cast(Optional[str], body.get("serviceUrl")) - - try: - await self._token_validator.validate_token(raw_token, service_url) - except Exception as e: - logger.warning(f"JWT token validation failed: {e}") - return HttpResponse(status=401, body={"error": "Unauthorized"}) - - token: TokenProtocol = cast(TokenProtocol, JsonWebToken(value=raw_token)) - else: - # No auth — use a default token - service_url = cast(Optional[str], body.get("serviceUrl")) - token = cast( + token: TokenProtocol = cast( TokenProtocol, SimpleNamespace( app_id="", @@ -119,6 +111,28 @@ async def handle_request(self, request: HttpRequest) -> HttpResponse: is_expired=lambda: False, ), ) + elif not self._token_validator: + # No credentials configured — reject the request + logger.error( + "No credentials configured. Configure client authentication " + "to securely receive messages, or set skip_auth=True to allow " + "unauthenticated requests." + ) + return HttpResponse(status=401, body={"error": "Authentication not configured"}) + else: + if not authorization.startswith("Bearer "): + return HttpResponse(status=401, body={"error": "Unauthorized"}) + + raw_token = authorization.removeprefix("Bearer ") + service_url = cast(Optional[str], body.get("serviceUrl")) + + try: + await self._token_validator.validate_token(raw_token, service_url) + except Exception as e: + logger.warning("JWT token validation failed: %s", e) + return HttpResponse(status=401, body={"error": "Unauthorized"}) + + token = cast(TokenProtocol, JsonWebToken(value=raw_token)) core_activity = CoreActivity.model_validate(body) activity_type = core_activity.type or "unknown" diff --git a/packages/apps/tests/test_http_server.py b/packages/apps/tests/test_http_server.py index bba4bcd8..3357b220 100644 --- a/packages/apps/tests/test_http_server.py +++ b/packages/apps/tests/test_http_server.py @@ -131,6 +131,64 @@ async def test_handle_activity_no_handler(self, server): assert result["status"] == 500 +class TestHttpServerNoCredentials: + """Test cases for HttpServer when no credentials are configured and skip_auth is not set.""" + + @pytest.fixture + def mock_adapter(self): + adapter = MagicMock() + adapter.register_route = MagicMock() + adapter.start = AsyncMock() + adapter.stop = AsyncMock() + return adapter + + @pytest.fixture + def server(self, mock_adapter): + server = HttpServer(mock_adapter) + server.initialize(credentials=None, skip_auth=False) + return server + + @pytest.mark.asyncio + async def test_rejects_request_when_no_credentials(self, server): + """Test that requests are rejected with 401 when no credentials are configured.""" + server.on_request = AsyncMock(return_value=InvokeResponse(status=200)) + + request = HttpRequest( + body={ + "type": "message", + "id": "test-123", + "serviceUrl": "https://attacker.com", + }, + headers={}, + ) + + result = await server.handle_request(request) + + assert result["status"] == 401 + assert result["body"] == {"error": "Authentication not configured"} + server.on_request.assert_not_called() + + @pytest.mark.asyncio + async def test_rejects_request_with_auth_header_when_no_credentials(self, server): + """Test that even requests with auth headers are rejected when no credentials are configured.""" + server.on_request = AsyncMock(return_value=InvokeResponse(status=200)) + + request = HttpRequest( + body={ + "type": "message", + "id": "test-123", + "serviceUrl": "https://example.com", + }, + headers={"authorization": "Bearer some-token"}, + ) + + result = await server.handle_request(request) + + assert result["status"] == 401 + assert result["body"] == {"error": "Authentication not configured"} + server.on_request.assert_not_called() + + class TestFastAPIAdapter: """Test cases for FastAPIAdapter.""" @@ -145,7 +203,7 @@ def test_register_route(self): """Test route registration on FastAPI app.""" adapter = FastAPIAdapter() - async def handler(req: HttpRequest) -> HttpResponse: + async def handler(request: HttpRequest) -> HttpResponse: return HttpResponse(status=200, body=None) adapter.register_route("POST", "/test", handler)