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:
parent
2a87dd346e
commit
6cd53e713c
|
|
@ -186,7 +186,14 @@ plugins:
|
||||||
- exp
|
- exp
|
||||||
|
|
||||||
- name: jwt
|
- name: jwt
|
||||||
service: voice-service
|
route: voice-api
|
||||||
|
config:
|
||||||
|
key_claim_name: kid
|
||||||
|
claims_to_verify:
|
||||||
|
- exp
|
||||||
|
|
||||||
|
- name: jwt
|
||||||
|
route: twilio-webhook
|
||||||
config:
|
config:
|
||||||
key_claim_name: kid
|
key_claim_name: kid
|
||||||
claims_to_verify:
|
claims_to_verify:
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
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():
|
async def _session_cleanup_loop():
|
||||||
"""Periodically remove sessions that have been disconnected longer than session_ttl.
|
"""Periodically remove sessions that have been disconnected longer than session_ttl.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ async def create_session(request: CreateSessionRequest, req: Request):
|
||||||
"task": None,
|
"task": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
websocket_url = f"/api/v1/voice/ws/{session_id}"
|
websocket_url = f"/ws/voice/{session_id}"
|
||||||
|
|
||||||
return SessionResponse(
|
return SessionResponse(
|
||||||
session_id=session_id,
|
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},
|
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(
|
return SessionResponse(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
status="disconnected",
|
status="disconnected",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue