diff --git a/packages/apps/README.md b/packages/apps/README.md index 9a21ccd23..32ddd5a30 100644 --- a/packages/apps/README.md +++ b/packages/apps/README.md @@ -39,6 +39,17 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): await app.start() ``` +## Unauthenticated Requests + +For local development only, JWT validation can be disabled explicitly: + +```python +app = App(dangerously_allow_unauthenticated_requests=True) +``` + +It can also be enabled with the `DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS=true` environment variable. +The previous `skip_auth` option is deprecated and remains supported for compatibility. + ## OAuth and Graph Integration ```python diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 54ad5b922..be154da5f 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -10,7 +10,7 @@ from .auth import * # noqa: F403 from .contexts import * # noqa: F403 from .events import * # noqa: F401, F403 -from .http import FastAPIAdapter, HttpServer, HttpServerAdapter +from .http import FastAPIAdapter, HttpServerAdapter from .http_stream import HttpStream from .options import AppOptions from .plugins import * # noqa: F401, F403 @@ -23,7 +23,6 @@ __all__: list[str] = [ "App", "AppOptions", - "HttpServer", "HttpServerAdapter", "FastAPIAdapter", "HttpStream", diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..b1f12c438 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -52,8 +52,9 @@ get_event_type_from_signature, is_registered_event, ) -from .http import FastAPIAdapter, HttpServer +from .http import FastAPIAdapter from .http.adapter import HttpRequest, HttpResponse +from .http.http_server import HttpServer from .options import AppOptions, InternalAppOptions from .plugins import PluginBase, PluginStartEvent from .routing import ActivityHandlerMixin, ActivityRouter @@ -209,8 +210,8 @@ async def initialize(self) -> None: self.server.on_request = self._process_activity_event self.server.initialize( credentials=self.credentials, - skip_auth=self.options.skip_auth, cloud=self.cloud, + dangerously_allow_unauthenticated_requests=self.options.dangerously_allow_unauthenticated_requests, ) self._initialized = True diff --git a/packages/apps/src/microsoft_teams/apps/http/__init__.py b/packages/apps/src/microsoft_teams/apps/http/__init__.py index f7751fd14..87811884f 100644 --- a/packages/apps/src/microsoft_teams/apps/http/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/http/__init__.py @@ -5,14 +5,12 @@ from .adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler, HttpServerAdapter from .fastapi_adapter import FastAPIAdapter -from .http_server import HttpServer __all__ = [ "HttpMethod", "HttpRequest", "HttpResponse", "HttpRouteHandler", - "HttpServer", "HttpServerAdapter", "FastAPIAdapter", ] 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 e11c411a9..d29f6f7d5 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -44,7 +44,7 @@ def __init__(self, adapter: HttpServerAdapter, messaging_endpoint: str = "/api/m self._messaging_endpoint = normalized_endpoint self._on_request: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]] = None self._token_validator: Optional[TokenValidator] = None - self._skip_auth: bool = False + self._dangerously_allow_unauthenticated_requests: bool = False self._cloud: CloudEnvironment = PUBLIC self._initialized: bool = False @@ -70,40 +70,42 @@ def on_request(self, callback: Optional[Callable[[ActivityEvent], Awaitable[Invo def initialize( self, credentials: Optional[Credentials] = None, - skip_auth: bool = False, cloud: Optional[CloudEnvironment] = None, + dangerously_allow_unauthenticated_requests: bool = False, ) -> None: """ Set up JWT validation and register the messaging endpoint route. Args: credentials: App credentials for JWT validation. - skip_auth: Whether to skip JWT validation. cloud: Optional cloud environment for sovereign cloud support. + dangerously_allow_unauthenticated_requests: Whether to skip JWT validation. """ if self._initialized: return - self._skip_auth = skip_auth + self._dangerously_allow_unauthenticated_requests = dangerously_allow_unauthenticated_requests self._cloud = cloud or PUBLIC app_id = getattr(credentials, "client_id", None) if credentials else None - if app_id and not skip_auth: + if app_id and not dangerously_allow_unauthenticated_requests: self._token_validator = TokenValidator.for_service( app_id, cloud=self._cloud, ) logger.debug("JWT validation enabled for %s", self._messaging_endpoint) - elif not app_id and not skip_auth: + elif not app_id and not dangerously_allow_unauthenticated_requests: logger.warning( - "No credentials configured and skip_auth is not enabled. " + "No credentials configured and dangerously_allow_unauthenticated_requests is not enabled. " "All incoming requests will be rejected. Configure client authentication " - "to securely receive messages, or set skip_auth=True for local development." + "to securely receive messages, or set dangerously_allow_unauthenticated_requests=True " + "for local development." ) - elif not app_id and skip_auth: + elif not app_id and dangerously_allow_unauthenticated_requests: logger.warning( "No credentials configured (CLIENT_ID / CLIENT_SECRET / TENANT_ID), " - "but skip_auth is enabled. Bot will accept unauthenticated requests on %s.", + "but dangerously_allow_unauthenticated_requests is enabled. " + "Bot will accept unauthenticated requests on %s.", self._messaging_endpoint, ) @@ -122,8 +124,8 @@ async def handle_request(self, request: HttpRequest) -> HttpResponse: # Validate JWT token authorization = headers.get("authorization") or headers.get("Authorization") or "" - if self._skip_auth: - # Auth explicitly skipped — use a default token + if self._dangerously_allow_unauthenticated_requests: + # Unauthenticated requests explicitly allowed: use a default token. service_url = cast(Optional[str], body.get("serviceUrl")) token: TokenProtocol = cast( TokenProtocol, diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index c9d7a363f..28ae9fbdb 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -5,6 +5,8 @@ from __future__ import annotations +import os +import warnings from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast @@ -16,6 +18,34 @@ from .http.adapter import HttpServerAdapter from .plugins import PluginBase +DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR = "DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS" +_TRUE_ENV_VALUES = {"1", "true", "yes", "on"} +_FALSE_ENV_VALUES = {"0", "false", "no", "off"} + + +def _parse_bool_env_var(name: str) -> Optional[bool]: + value = os.getenv(name) + if value is None: + return None + + normalized_value = value.strip().lower() + if not normalized_value: + return None + if normalized_value in _TRUE_ENV_VALUES: + return True + if normalized_value in _FALSE_ENV_VALUES: + return False + + raise ValueError(f"{name} must be a boolean value: true/false, 1/0, yes/no, or on/off.") + + +def _warn_skip_auth_deprecated() -> None: + warnings.warn( + "skip_auth is deprecated; use dangerously_allow_unauthenticated_requests instead.", + DeprecationWarning, + stacklevel=3, + ) + class AppOptions(TypedDict, total=False): """Configuration options for the Teams App.""" @@ -50,7 +80,13 @@ class AppOptions(TypedDict, total=False): # Infrastructure storage: Optional[Storage[str, Any]] plugins: Optional[List[PluginBase]] + dangerously_allow_unauthenticated_requests: Optional[bool] + """ + Whether to accept incoming requests without JWT validation. + Defaults to the DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS environment variable, or False. + """ skip_auth: Optional[bool] + """Deprecated. Use dangerously_allow_unauthenticated_requests instead.""" # HTTP adapter http_server_adapter: Optional[HttpServerAdapter] @@ -90,7 +126,8 @@ class InternalAppOptions: """Internal dataclass for AppOptions with defaults and non-nullable fields.""" # Fields with defaults - skip_auth: bool = False + dangerously_allow_unauthenticated_requests: bool = False + """Whether to accept incoming requests without JWT validation.""" default_connection_name: str = "graph" """The OAuth connection name to use for authentication.""" plugins: List[PluginBase] = field(default_factory=lambda: []) @@ -146,6 +183,20 @@ def from_typeddict(cls, options: AppOptions) -> "InternalAppOptions": InternalAppOptions with proper defaults and non-nullable required fields """ kwargs: dict[str, Any] = {k: v for k, v in options.items() if v is not None} + dangerously_allow_unauthenticated_requests = kwargs.pop("dangerously_allow_unauthenticated_requests", None) + skip_auth = kwargs.pop("skip_auth", None) + + if skip_auth is not None: + _warn_skip_auth_deprecated() + + if dangerously_allow_unauthenticated_requests is None: + if skip_auth is not None: + dangerously_allow_unauthenticated_requests = skip_auth + else: + dangerously_allow_unauthenticated_requests = ( + _parse_bool_env_var(DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR) or False + ) + kwargs["dangerously_allow_unauthenticated_requests"] = dangerously_allow_unauthenticated_requests return cls(**kwargs) @@ -160,6 +211,7 @@ def merge_app_options_with_defaults(**options: Unpack[AppOptions]) -> AppOptions AppOptions with defaults applied """ defaults: AppOptions = { + "dangerously_allow_unauthenticated_requests": False, "skip_auth": False, "default_connection_name": "graph", "plugins": [], diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index a090860f5..57585c4f9 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -763,6 +763,59 @@ def test_service_url_priority(self, mock_storage): app3 = App(**options3) assert app3.api.service_url == "https://options.service.url/teams" + def test_dangerously_allow_unauthenticated_requests_from_environment(self, mock_storage): + """Test that unauthenticated requests can be enabled from the environment.""" + options = AppOptions( + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with patch.dict("os.environ", {"DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS": "true"}, clear=False): + app = App(**options) + + assert app.options.dangerously_allow_unauthenticated_requests is True + + def test_dangerously_allow_unauthenticated_requests_option_overrides_environment(self, mock_storage): + """Test that explicit unauthenticated request options override environment configuration.""" + options = AppOptions( + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + dangerously_allow_unauthenticated_requests=False, + ) + + with patch.dict("os.environ", {"DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS": "true"}, clear=False): + app = App(**options) + + assert app.options.dangerously_allow_unauthenticated_requests is False + + def test_invalid_dangerously_allow_unauthenticated_requests_environment_raises(self, mock_storage): + """Test that invalid boolean environment values are rejected.""" + options = AppOptions( + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + ) + + with patch.dict("os.environ", {"DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS": "sometimes"}, clear=False): + with pytest.raises(ValueError, match="DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS"): + App(**options) + + def test_deprecated_skip_auth_option_remains_supported(self, mock_storage): + """Test that deprecated skip_auth continues to configure unauthenticated requests.""" + options = AppOptions( + storage=mock_storage, + client_id="test-client-id", + client_secret="test-client-secret", + skip_auth=True, + ) + + with pytest.warns(DeprecationWarning, match="skip_auth is deprecated"): + app = App(**options) + + assert app.options.dangerously_allow_unauthenticated_requests is True + # Tests for App.send() proactive targeted message validation @pytest.mark.asyncio @@ -799,7 +852,11 @@ class TestAppInitialize: @pytest.mark.asyncio async def test_initialize_enables_send(self): """After initialize(), app.send() should work without starting the server.""" - app = App(client_id="test-id", client_secret="test-secret", skip_auth=True) + app = App( + client_id="test-id", + client_secret="test-secret", + dangerously_allow_unauthenticated_requests=True, + ) app.activity_sender.send = AsyncMock( return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi")) ) @@ -820,7 +877,12 @@ class BadPlugin(PluginBase): async def on_init(self): raise RuntimeError("plugin init failed") - app = App(client_id="test-id", client_secret="test-secret", skip_auth=True, plugins=[BadPlugin()]) + app = App( + client_id="test-id", + client_secret="test-secret", + dangerously_allow_unauthenticated_requests=True, + plugins=[BadPlugin()], + ) errors = [] app.events.on("error", lambda e: errors.append(e)) @@ -888,5 +950,6 @@ def test_merge_with_defaults(self): result = merge_app_options_with_defaults(client_id="test-id") assert result["client_id"] == "test-id" + assert result["dangerously_allow_unauthenticated_requests"] is False assert result["skip_auth"] is False assert result["default_connection_name"] == "graph" diff --git a/packages/apps/tests/test_http_server.py b/packages/apps/tests/test_http_server.py index 62b894136..00455b825 100644 --- a/packages/apps/tests/test_http_server.py +++ b/packages/apps/tests/test_http_server.py @@ -12,12 +12,23 @@ ConfigResponse, InvokeResponse, ) -from microsoft_teams.apps.http import FastAPIAdapter, HttpServer +from microsoft_teams.apps.http import FastAPIAdapter from microsoft_teams.apps.http.adapter import HttpRequest, HttpResponse +from microsoft_teams.apps.http.http_server import HttpServer class TestHttpServer: - """Test cases for HttpServer public interface.""" + """Test cases for HttpServer implementation.""" + + def test_http_server_is_not_publicly_exported(self): + """HttpServer is an implementation detail and should not be exported.""" + import microsoft_teams.apps as apps + import microsoft_teams.apps.http as http + + assert "HttpServer" not in apps.__all__ + assert not hasattr(apps, "HttpServer") + assert "HttpServer" not in http.__all__ + assert not hasattr(http, "HttpServer") @pytest.fixture def mock_adapter(self): @@ -41,8 +52,8 @@ def test_init(self, server, mock_adapter): def test_initialize_idempotent(self, server, mock_adapter): """Test that initialize can be called multiple times safely.""" - server.initialize(skip_auth=True) - server.initialize(skip_auth=True) + server.initialize(dangerously_allow_unauthenticated_requests=True) + server.initialize(dangerously_allow_unauthenticated_requests=True) # Should only register route once assert mock_adapter.register_route.call_count == 1 @@ -115,7 +126,7 @@ async def mock_handler(event): return expected_response server.on_request = mock_handler - server.initialize(skip_auth=True) + server.initialize(dangerously_allow_unauthenticated_requests=True) request = HttpRequest( body={ @@ -139,7 +150,7 @@ async def failing_handler(event): raise ValueError("Handler failed") server.on_request = failing_handler - server.initialize(skip_auth=True) + server.initialize(dangerously_allow_unauthenticated_requests=True) request = HttpRequest( body={"type": "message", "id": "test-123"}, @@ -153,7 +164,7 @@ async def failing_handler(event): @pytest.mark.asyncio async def test_handle_activity_no_handler(self, server): """Test activity handling when no on_request handler is set.""" - server.initialize(skip_auth=True) + server.initialize(dangerously_allow_unauthenticated_requests=True) request = HttpRequest( body={"type": "message", "id": "test-123"}, @@ -200,7 +211,7 @@ async def test_rejects_invalid_jwt(self, server, mock_adapter): class TestHttpServerNoCredentials: - """Test cases for HttpServer when no credentials are configured and skip_auth is not set.""" + """Test cases for HttpServer when no credentials are configured and unauthenticated requests are not allowed.""" @pytest.fixture def mock_adapter(self): @@ -213,7 +224,7 @@ def mock_adapter(self): @pytest.fixture def server(self, mock_adapter): server = HttpServer(mock_adapter) - server.initialize(credentials=None, skip_auth=False) + server.initialize(credentials=None, dangerously_allow_unauthenticated_requests=False) return server @pytest.mark.asyncio