Files
hope-voice-api/web/token_server.py
T

81 lines
3.9 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
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)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8091, access_log=False)