Dashboard authenticates: token prompt + bearer on REST/WS/recordings

The UI never sent the bearer token, so with API_TOKEN set every data
call 401'd and /ws/events was rejected pre-accept (the 403s in the
uvicorn log). The API client now keeps the token in localStorage,
attaches Authorization to every request, prompts once on a 401 and
retries, and appends ?token= to the WebSocket connect — the page's
reconnect loop picks the token up after the first prompt.

require_token also accepts a ?token= query parameter (same convention
as the WebSocket) because <audio> elements fetching recordings can't
set headers; recordingUrl() rides the token there.

The dashboard header's status call moved to a new authenticated
GET /api/v1/status — its old source was the JSON root endpoint that
the dashboard itself replaced at /.

Two new auth tests (query-param accepted / wrong query-param 401);
dashboard rebuilt. Verified live: WS rejected without token and
connected with ?token=, status 200, ?token=wrong 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 06:21:55 -04:00
parent dff21f7d5c
commit e7c84885d9
4 changed files with 100 additions and 17 deletions

View File

@@ -4,7 +4,7 @@ API Dependencies — Shared dependency injection for all routes.
import secrets
from fastapi import Header, HTTPException, Request
from fastapi import Header, HTTPException, Query, Request
from config import get_settings
from core.gateway import AIPSTNGateway
@@ -26,20 +26,27 @@ def get_routing_service(request: Request):
return routing
def require_token(authorization: str | None = Header(default=None)) -> None:
def require_token(
authorization: str | None = Header(default=None),
token: str | None = Query(default=None),
) -> None:
"""
Enforce the static bearer token (API_TOKEN) on REST routes.
A `token` query parameter is accepted alongside the Authorization
header for clients that can't set headers — <audio>/<a> elements
fetching recordings — matching the WebSocket convention.
An empty configured token disables auth; startup refuses that
combination unless the server is bound to loopback.
"""
token = get_settings().api_token.get_secret_value()
if not token:
expected = get_settings().api_token.get_secret_value()
if not expected:
return
supplied = ""
supplied = token or ""
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:]
if not secrets.compare_digest(supplied, token):
if not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=401,
detail="Missing or invalid bearer token",