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
67 changes: 65 additions & 2 deletions backend/app/api/feishu.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Feishu OAuth and Channel API routes."""

import hashlib
import hmac
import json
import uuid

from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, Response
from lark_oapi.core.utils import AESCipher
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -31,6 +35,50 @@
)


def _verify_and_decode_feishu_callback(
body_bytes: bytes,
headers: dict[str, str],
config: ChannelConfig,
) -> dict | None:
"""Authenticate a Feishu callback before any event data is consumed."""
try:
envelope = json.loads(body_bytes)
if not isinstance(envelope, dict):
return None

encrypt_key = (config.encrypt_key or "").strip()
encrypted = envelope.get("encrypt")
if encrypted:
if not encrypt_key:
return None
payload = json.loads(AESCipher(encrypt_key).decrypt_str(encrypted))
else:
payload = envelope
if not isinstance(payload, dict):
return None

verification_token = (config.verification_token or "").strip()
actual_token = str((payload.get("header") or {}).get("token") or "")
if not verification_token or not hmac.compare_digest(actual_token, verification_token):
return None

event_type = str((payload.get("header") or {}).get("event_type") or "")
if encrypt_key and event_type != "url_verification":
timestamp = headers.get("x-lark-request-timestamp", "")
nonce = headers.get("x-lark-request-nonce", "")
signature = headers.get("x-lark-signature", "")
if not timestamp or not nonce or not signature:
return None
expected = hashlib.sha256(
(timestamp + nonce + encrypt_key).encode() + body_bytes
).hexdigest()
if not hmac.compare_digest(signature, expected):
return None
return payload
except (UnicodeDecodeError, ValueError, TypeError):
return None


# ─── OAuth ──────────────────────────────────────────────

@router.get("/auth/feishu/callback")
Expand Down Expand Up @@ -391,7 +439,22 @@ async def feishu_event_webhook(
request: Request,
):
"""Handle Feishu event callback for a specific agent's bot."""
body = await request.json()
body_bytes = await request.body()
async with _async_session() as db:
result = await db.execute(
select(ChannelConfig).where(
ChannelConfig.agent_id == agent_id,
ChannelConfig.channel_type == "feishu",
)
)
config = result.scalar_one_or_none()
if not config:
return Response(status_code=status.HTTP_404_NOT_FOUND)

body = _verify_and_decode_feishu_callback(body_bytes, dict(request.headers), config)
if body is None:
logger.warning("[Feishu] Rejected unauthenticated callback for {}", agent_id)
return Response(status_code=status.HTTP_401_UNAUTHORIZED)

# Handle verification challenge
if "challenge" in body:
Expand Down
69 changes: 63 additions & 6 deletions backend/app/api/teams.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Microsoft Teams Bot Channel API routes."""

import hmac
import json
import os
import time
Expand All @@ -8,6 +9,7 @@

import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from jose import JWTError, jwk, jwt
from loguru import logger
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -37,6 +39,52 @@
# In-memory cache for OAuth tokens
_teams_tokens: dict[str, dict] = {} # agent_id -> {access_token, expires_at}

_BOT_FRAMEWORK_OPENID_CONFIG = "https://login.botframework.com/v1/.well-known/openidconfiguration"
_BOT_FRAMEWORK_ISSUER = "https://api.botframework.com"


async def _validate_teams_callback(
authorization: str | None,
activity: dict,
config: ChannelConfig,
) -> bool:
"""Verify a Bot Framework JWT and bind its serviceUrl to the activity."""
if not authorization or not authorization.lower().startswith("bearer "):
return False
token = authorization[7:].strip()
app_id = (config.app_id or "").strip()
service_url = str(activity.get("serviceUrl") or "")
if not token or not app_id or not service_url:
return False
try:
header = jwt.get_unverified_header(token)
algorithm = header.get("alg")
key_id = header.get("kid")
if not algorithm or algorithm == "none" or not key_id:
return False
async with httpx.AsyncClient(timeout=10) as client:
metadata_response = await client.get(_BOT_FRAMEWORK_OPENID_CONFIG)
metadata_response.raise_for_status()
metadata = metadata_response.json()
jwks_response = await client.get(metadata["jwks_uri"])
jwks_response.raise_for_status()
keys = jwks_response.json().get("keys", [])
key_data = next((item for item in keys if item.get("kid") == key_id), None)
if not key_data:
return False
claims = jwt.decode(
token,
jwk.construct(key_data, algorithm),
algorithms=[algorithm],
audience=app_id,
issuer=_BOT_FRAMEWORK_ISSUER,
options={"leeway": 300},
)
claimed_service_url = str(claims.get("serviceurl") or claims.get("serviceUrl") or "")
return bool(claimed_service_url) and hmac.compare_digest(claimed_service_url, service_url)
except (JWTError, KeyError, TypeError, ValueError, httpx.HTTPError):
return False


async def _get_teams_access_token(config: ChannelConfig) -> str | None:
"""Get or refresh Microsoft Teams access token.
Expand Down Expand Up @@ -230,9 +278,12 @@ async def configure_teams_channel(
tenant_id = data.get("tenant_id", "").strip() # Optional: for single-tenant apps
use_managed_identity = data.get("use_managed_identity", False) # Optional: use Azure Managed Identity

# Validate: either managed identity OR app_id + app_secret required
if not use_managed_identity and (not app_id or not app_secret):
raise HTTPException(status_code=422, detail="Either use_managed_identity must be enabled, or app_id and app_secret are required")
# The App ID is required to verify the incoming Bot Framework JWT audience.
if not app_id or (not use_managed_identity and not app_secret):
raise HTTPException(
status_code=422,
detail="app_id is required; app_secret is required unless managed identity is enabled",
)

result = await db.execute(
select(ChannelConfig).where(
Expand All @@ -242,7 +293,7 @@ async def configure_teams_channel(
)
existing = result.scalar_one_or_none()
if existing:
existing.app_id = app_id if not use_managed_identity else existing.app_id
existing.app_id = app_id
existing.app_secret = app_secret if not use_managed_identity else existing.app_secret
existing.is_configured = True
# Store tenant_id and use_managed_identity in extra_config
Expand All @@ -266,7 +317,7 @@ async def configure_teams_channel(
config = ChannelConfig(
agent_id=agent_id,
channel_type="microsoft_teams",
app_id=app_id if not use_managed_identity else None,
app_id=app_id,
app_secret=app_secret if not use_managed_identity else None,
is_configured=True,
extra_config=extra_config,
Expand Down Expand Up @@ -381,7 +432,13 @@ async def teams_event_webhook(
logger.warning(f"Teams: Webhook received for unconfigured agent {agent_id}")
return Response(status_code=404)

# Extract serviceUrl from the activity for sending replies
if not await _validate_teams_callback(
request.headers.get("authorization"), activity, config
):
logger.warning("Teams: Rejected unauthenticated callback for agent {}", agent_id)
return Response(status_code=401)

# This value is now authenticated by the JWT serviceUrl claim above.
service_url = activity.get("serviceUrl")
if service_url:
if config.extra_config.get("service_url") != service_url:
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/test_feishu_channel_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
from datetime import UTC, datetime
from types import SimpleNamespace
import uuid
Expand Down Expand Up @@ -48,6 +49,40 @@ def __call__(self):
return next(self.sessions)


def test_feishu_callback_rejects_missing_or_mismatched_verification_token() -> None:
config = SimpleNamespace(verification_token="expected", encrypt_key="")

assert feishu._verify_and_decode_feishu_callback(
b'{"header":{"token":"unexpected"}}', {}, config # type: ignore[arg-type]
) is None


def test_feishu_callback_accepts_a_matching_verification_token() -> None:
config = SimpleNamespace(verification_token="expected", encrypt_key="")
payload = b'{"header":{"token":"expected","event_type":"im.message.receive_v1"}}'

assert feishu._verify_and_decode_feishu_callback(payload, {}, config) == {
"header": {"token": "expected", "event_type": "im.message.receive_v1"}
}


def test_feishu_callback_rejects_an_invalid_signed_request() -> None:
config = SimpleNamespace(verification_token="expected", encrypt_key="encrypt-key")
payload = b'{"header":{"token":"expected","event_type":"im.message.receive_v1"}}'
headers = {
"x-lark-request-timestamp": "1",
"x-lark-request-nonce": "2",
"x-lark-signature": "invalid",
}

assert feishu._verify_and_decode_feishu_callback(payload, headers, config) is None

headers["x-lark-signature"] = hashlib.sha256(
b"12encrypt-key" + payload
).hexdigest()
assert feishu._verify_and_decode_feishu_callback(payload, headers, config) is not None


def _runtime(tenant_id: uuid.UUID) -> ChatRuntimeIntake:
run_id = uuid.uuid4()
return ChatRuntimeIntake(
Expand Down
34 changes: 31 additions & 3 deletions backend/tests/test_http_channel_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ async def close(self) -> None:


class _Request:
def __init__(self, body: dict) -> None:
def __init__(self, body: dict, headers: dict[str, str] | None = None) -> None:
self._body = json.dumps(body).encode()
self.headers: dict[str, str] = {}
self.headers = headers or {}

async def body(self) -> bytes:
return self._body
Expand Down Expand Up @@ -176,7 +176,10 @@ async def test_teams_webhook_uses_runtime_intake(monkeypatch) -> None:
config = SimpleNamespace(
app_id="bot-1",
app_secret="",
extra_config={"use_managed_identity": False},
extra_config={
"use_managed_identity": False,
"service_url": "https://smba.trafficmanager.net/teams/",
},
is_connected=False,
)
agent = SimpleNamespace(id=agent_id, tenant_id=tenant_id, creator_id=uuid.uuid4())
Expand All @@ -201,10 +204,14 @@ async def enqueue(_db, **kwargs):
calls["intake"] = kwargs
return intake

async def validate_callback(*_args, **_kwargs):
return True

monkeypatch.setattr(channel_user_service, "resolve_channel_user", resolve_user)
monkeypatch.setattr(teams, "find_or_create_channel_session", find_session)
monkeypatch.setattr(teams, "_load_agent_and_model", load_model)
monkeypatch.setattr(teams, "enqueue_channel_chat_runtime", enqueue)
monkeypatch.setattr(teams, "_validate_teams_callback", validate_callback)

result = await teams.teams_event_webhook(
agent_id,
Expand All @@ -219,6 +226,7 @@ async def enqueue(_db, **kwargs):
"id": "teams-conversation-1",
"conversationType": "personal",
},
"serviceUrl": "https://smba.trafficmanager.net/teams/",
}
), # type: ignore[arg-type]
db, # type: ignore[arg-type]
Expand All @@ -243,6 +251,26 @@ async def enqueue(_db, **kwargs):
)


@pytest.mark.asyncio
async def test_teams_webhook_rejects_requests_without_a_valid_jwt() -> None:
agent_id = uuid.uuid4()
config = SimpleNamespace(app_id="bot-1", extra_config={})
db = _Session(config)

result = await teams.teams_event_webhook(
agent_id,
_Request(
{
"type": "message",
"serviceUrl": "https://smba.trafficmanager.net/teams/",
}
), # type: ignore[arg-type]
db, # type: ignore[arg-type]
)

assert result.status_code == 401


@pytest.mark.asyncio
async def test_whatsapp_webhook_uses_runtime_intake(monkeypatch) -> None:
tenant_id = uuid.uuid4()
Expand Down