From 6cd53e713cf7708583bc9a144b35c6685ce3370e Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 23 Feb 2026 21:30:11 -0800 Subject: [PATCH] fix: bypass JWT for voice WebSocket route (fixes 401 on WS upgrade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: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 --- packages/gateway/config/kong.yml | 9 ++++++++- packages/services/voice-service/src/api/main.py | 15 +++++++++++++++ .../voice-service/src/api/session_router.py | 4 ++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/gateway/config/kong.yml b/packages/gateway/config/kong.yml index a219503..7e4b8fd 100644 --- a/packages/gateway/config/kong.yml +++ b/packages/gateway/config/kong.yml @@ -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: diff --git a/packages/services/voice-service/src/api/main.py b/packages/services/voice-service/src/api/main.py index 2842d10..f4a3be4 100644 --- a/packages/services/voice-service/src/api/main.py +++ b/packages/services/voice-service/src/api/main.py @@ -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. diff --git a/packages/services/voice-service/src/api/session_router.py b/packages/services/voice-service/src/api/session_router.py index d81c4c9..6915903 100644 --- a/packages/services/voice-service/src/api/session_router.py +++ b/packages/services/voice-service/src/api/session_router.py @@ -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",