Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions packages/apps/src/microsoft_teams/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,7 +23,6 @@
__all__: list[str] = [
"App",
"AppOptions",
"HttpServer",
"HttpServerAdapter",
"FastAPIAdapter",
"HttpStream",
Expand Down
5 changes: 3 additions & 2 deletions packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions packages/apps/src/microsoft_teams/apps/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
26 changes: 14 additions & 12 deletions packages/apps/src/microsoft_teams/apps/http/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Comment on lines 70 to 75
"""
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,
)

Expand All @@ -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,
Expand Down
54 changes: 53 additions & 1 deletion packages/apps/src/microsoft_teams/apps/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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: [])
Expand Down Expand Up @@ -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()
Comment thread
heyitsaamir marked this conversation as resolved.

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)


Expand All @@ -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": [],
Expand Down
67 changes: 65 additions & 2 deletions packages/apps/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
)
Expand All @@ -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))

Expand Down Expand Up @@ -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"
Loading
Loading