122 lines
6.2 KiB
Python
122 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Authenticated room bootstrap. All identity/routing is resolved server-side."""
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
try:
|
|
from web.voice_catalog import catalog
|
|
except ModuleNotFoundError: # Direct execution: python /app/web/token_server.py
|
|
from voice_catalog import catalog
|
|
|
|
API_KEY = os.getenv("LIVEKIT_API_KEY", "")
|
|
API_SECRET = os.getenv("LIVEKIT_API_SECRET", "")
|
|
AGENT_NAME = os.getenv("VOICE_AGENT_NAME", "voice-assistant")
|
|
CONTROL_URL = os.getenv("CONTROL_URL", "http://127.0.0.1:8092").rstrip("/")
|
|
CONTROL_SECRET = os.getenv("CONTROL_SERVICE_SECRET", "")
|
|
COOKIE_NAME = "hope_session"
|
|
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
|
|
|
|
def b64url(data: bytes) -> str:
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
|
|
|
|
def make_token(room_name: str, identity: str) -> str:
|
|
if not API_KEY or not API_SECRET:
|
|
raise RuntimeError("LiveKit credentials are not configured")
|
|
now = int(time.time())
|
|
payload = {"iss": API_KEY, "sub": identity, "nbf": now - 5, "exp": now + 300,
|
|
"jti": str(uuid.uuid4()), "video": {"roomJoin": True, "room": room_name,
|
|
"canPublish": True, "canSubscribe": True, "canPublishData": True,
|
|
"canPublishSources": ["microphone"]},
|
|
"roomConfig": {"agents": [{"agentName": AGENT_NAME, "jobType": "JT_ROOM"}]}}
|
|
head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
|
|
body = b64url(json.dumps(payload).encode())
|
|
sig = hmac.new(API_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
|
|
return f"{head}.{body}.{b64url(sig)}"
|
|
|
|
|
|
@app.post("/token")
|
|
async def token(request: Request):
|
|
headers = {"Cache-Control": "no-store"}
|
|
if not CONTROL_SECRET or not API_KEY or not API_SECRET:
|
|
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
|
|
auth = request.headers.get("authorization", "")
|
|
bearer = auth[7:] if auth.startswith("Bearer ") else ""
|
|
credential = bearer or request.cookies.get(COOKIE_NAME, "")
|
|
if not credential or len(credential) > 512:
|
|
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
|
|
if not bearer:
|
|
origin = request.headers.get("origin", "")
|
|
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
|
if origin not in allowed:
|
|
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
control_headers = {"X-Control-Secret": CONTROL_SECRET}
|
|
response = await client.post(f"{CONTROL_URL}/internal/resolve-session", headers=control_headers,
|
|
json={"token": credential})
|
|
if response.status_code != 200:
|
|
status = response.status_code if response.status_code in (401,403,503) else 503
|
|
return JSONResponse({"error": "Sign in required" if status==401 else "Your private agent is not ready"}, status, headers=headers)
|
|
account = response.json()
|
|
user = account["user"]
|
|
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
|
|
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
|
|
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
|
|
"session_token": credential})
|
|
grant.raise_for_status()
|
|
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
|
|
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
|
|
except (httpx.HTTPError, ValueError, KeyError):
|
|
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
|
|
|
@app.get("/voices")
|
|
async def voices(request: Request):
|
|
"""Resolve the caller at the trusted control boundary before reading the cache."""
|
|
headers = {"Cache-Control": "no-store"}
|
|
if not CONTROL_SECRET:
|
|
return JSONResponse({"error": "Service configuration unavailable"}, 503, headers=headers)
|
|
auth = request.headers.get("authorization", "")
|
|
bearer = auth[7:] if auth.startswith("Bearer ") else ""
|
|
credential = bearer or request.cookies.get(COOKIE_NAME, "")
|
|
if not credential or len(credential) > 512:
|
|
return JSONResponse({"error": "Sign in required"}, 401, headers=headers)
|
|
# Safe GET requests normally have no Origin. If supplied with cookie auth,
|
|
# enforce the same configured origin allowlist as the bootstrap boundary.
|
|
origin = request.headers.get("origin", "")
|
|
if not bearer and origin:
|
|
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
|
if origin not in allowed:
|
|
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
response = await client.post(f"{CONTROL_URL}/internal/resolve-session",
|
|
headers={"X-Control-Secret": CONTROL_SECRET},
|
|
json={"token": credential})
|
|
if response.status_code != 200:
|
|
status = response.status_code if response.status_code in (401, 403, 503) else 503
|
|
return JSONResponse({"error": "Sign in required" if status == 401 else
|
|
"Your private agent is not ready"}, status, headers=headers)
|
|
# Verify the expected control response without exposing private metadata.
|
|
account = response.json()
|
|
if not isinstance(account, dict) or not isinstance(account.get("user"), dict) or not account["user"].get("id"):
|
|
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
|
except (httpx.HTTPError, ValueError, TypeError):
|
|
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
|
return JSONResponse(await catalog.get(), headers=headers)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)
|