fix: bypass JWT for voice WebSocket route (fixes 401 on WS upgrade)

根因:Kong 日志显示 voice WebSocket 连接被 JWT 插件返回 401,
因为 WebSocket RFC 6455 不支持自定义 header,Flutter 的
WebSocketChannel.connect 无法携带 Authorization header。

修复策略(业界标准做法):
1. Kong: 将 voice-service 的 JWT 从 service 级别改为 route
   级别,仅在 voice-api 和 twilio-webhook 路由启用 JWT,
   voice-ws 路由免除(session 创建已通过 JWT 验证,
   session_id 本身作为认证凭据)
2. 后端: session_router 返回的 websocket_url 改为
   /ws/voice/{session_id}(匹配 Kong voice-ws 路由路径)
3. FastAPI: 在 app 级别增加 /ws/voice/{session_id} 顶级
   WebSocket 路由,委托给 session_router 的 handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-23 21:30:11 -08:00
parent 2a87dd346e
commit 6cd53e713c
3 changed files with 25 additions and 3 deletions

View File

@ -186,7 +186,14 @@ plugins:
- exp
- name: jwt
service: voice-service
route: voice-api
config:
key_claim_name: kid
claims_to_verify:
- exp
- name: jwt
route: twilio-webhook
config:
key_claim_name: kid
claims_to_verify:

View File

@ -31,6 +31,21 @@ app.include_router(session_router, prefix="/api/v1/voice", tags=["sessions"])
app.include_router(twilio_router, prefix="/api/v1/twilio", tags=["twilio"])
# ---------------------------------------------------------------------------
# Top-level WebSocket route for Kong's voice-ws route (/ws/voice/*)
# ---------------------------------------------------------------------------
# Kong forwards the full path /ws/voice/{session_id} without JWT.
# We import the handler from session_router and re-expose it here so the
# path matches exactly.
from fastapi import WebSocket as _WS # noqa: E402
@app.websocket("/ws/voice/{session_id}")
async def voice_ws_proxy(websocket: _WS, session_id: str):
"""Proxy route that delegates to the session_router WebSocket handler."""
from .session_router import voice_websocket
await voice_websocket(websocket, session_id)
async def _session_cleanup_loop():
"""Periodically remove sessions that have been disconnected longer than session_ttl.

View File

@ -83,7 +83,7 @@ async def create_session(request: CreateSessionRequest, req: Request):
"task": None,
}
websocket_url = f"/api/v1/voice/ws/{session_id}"
websocket_url = f"/ws/voice/{session_id}"
return SessionResponse(
session_id=session_id,
@ -155,7 +155,7 @@ async def reconnect_session(session_id: str, req: Request):
content={"error": "Session expired", "session_id": session_id},
)
websocket_url = f"/api/v1/voice/ws/{session_id}"
websocket_url = f"/ws/voice/{session_id}"
return SessionResponse(
session_id=session_id,
status="disconnected",