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:
19
api/deps.py
19
api/deps.py
@@ -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",
|
||||
|
||||
@@ -9,14 +9,50 @@ import type {
|
||||
TranscriptRow,
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
|
||||
// Kept in localStorage; a 401 prompts once and retries.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const TOKEN_KEY = 'hold-slayer-token';
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
}
|
||||
|
||||
function withAuth(init: RequestInit): RequestInit {
|
||||
const token = getToken();
|
||||
if (!token) return init;
|
||||
return {
|
||||
...init,
|
||||
headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let res = await fetch(path, withAuth(init));
|
||||
if (res.status === 401) {
|
||||
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
|
||||
if (supplied !== null && supplied.trim()) {
|
||||
setToken(supplied.trim());
|
||||
res = await fetch(path, withAuth(init));
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
const res = await request(path);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function fetchGatewayStatus(): Promise<GatewayStatus> {
|
||||
return get<GatewayStatus>('/');
|
||||
return get<GatewayStatus>('/api/v1/status');
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthStatus> {
|
||||
@@ -32,7 +68,7 @@ export async function fetchDevices(): Promise<DeviceStatus[]> {
|
||||
}
|
||||
|
||||
export async function hangupCall(callId: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/calls/${callId}/hangup`, { method: 'POST' });
|
||||
const res = await request(`/api/v1/calls/${callId}/hangup`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
@@ -53,7 +89,11 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
|
||||
}
|
||||
|
||||
export function recordingUrl(callId: string): string {
|
||||
return `/api/v1/calls/${callId}/recording`;
|
||||
// <audio> can't send headers, so the token rides as a query param
|
||||
// (accepted server-side alongside the Authorization header).
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return `/api/v1/calls/${callId}/recording${suffix}`;
|
||||
}
|
||||
|
||||
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
|
||||
@@ -61,7 +101,7 @@ export async function fetchRoutingRules(): Promise<RoutingRule[]> {
|
||||
}
|
||||
|
||||
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
|
||||
const res = await fetch('/api/v1/routing/rules', {
|
||||
const res = await request('/api/v1/routing/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rule),
|
||||
@@ -74,7 +114,7 @@ export async function updateRoutingRule(
|
||||
ruleId: string,
|
||||
patch: Partial<RoutingRule>,
|
||||
): Promise<RoutingRule> {
|
||||
const res = await fetch(`/api/v1/routing/rules/${ruleId}`, {
|
||||
const res = await request(`/api/v1/routing/rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
@@ -84,12 +124,12 @@ export async function updateRoutingRule(
|
||||
}
|
||||
|
||||
export async function deleteRoutingRule(ruleId: string): Promise<void> {
|
||||
const res = await fetch(`/api/v1/routing/rules/${ruleId}`, { method: 'DELETE' });
|
||||
const res = await request(`/api/v1/routing/rules/${ruleId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
|
||||
const res = await fetch(`/api/v1/routing/devices/${deviceId}/dnd`, {
|
||||
const res = await request(`/api/v1/routing/devices/${deviceId}/dnd`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
@@ -102,7 +142,9 @@ export function connectEventStream(
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events`);
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
try {
|
||||
|
||||
29
main.py
29
main.py
@@ -261,9 +261,13 @@ app = FastAPI(
|
||||
# call_history must register before calls: both live under /api/v1/calls and
|
||||
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||
_auth = [Depends(require_token)]
|
||||
app.include_router(call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth)
|
||||
app.include_router(
|
||||
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
||||
)
|
||||
app.include_router(calls.router, prefix="/api/v1/calls", tags=["Calls"], dependencies=_auth)
|
||||
app.include_router(call_flows.router, prefix="/api/v1/call-flows", tags=["Call Flows"], dependencies=_auth)
|
||||
app.include_router(
|
||||
call_flows.router, prefix="/api/v1/call-flows", tags=["Call Flows"], dependencies=_auth
|
||||
)
|
||||
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
||||
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
||||
# WebSocket endpoints check the token themselves (query param or header)
|
||||
@@ -273,6 +277,27 @@ app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
|
||||
|
||||
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
||||
async def api_status():
|
||||
"""Gateway status summary for the dashboard header."""
|
||||
gateway = getattr(app.state, "gateway", None)
|
||||
if gateway:
|
||||
status = await gateway.status()
|
||||
return {
|
||||
"name": "Hold Slayer Gateway",
|
||||
"version": "0.1.0",
|
||||
"status": "running",
|
||||
"uptime": status["uptime"],
|
||||
"active_calls": status["active_calls"],
|
||||
"trunk": status["trunk"],
|
||||
}
|
||||
return {
|
||||
"name": "Hold Slayer Gateway",
|
||||
"version": "0.1.0",
|
||||
"status": "starting",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["System"])
|
||||
async def health():
|
||||
"""
|
||||
|
||||
@@ -52,6 +52,15 @@ class TestBearerToken:
|
||||
resp = await client.get("/api/v1/calls/active")
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_query_param_token_accepted(self, token_enabled, client):
|
||||
"""<audio>/<a> elements can't set headers — ?token= must work."""
|
||||
resp = await client.get(f"/api/v1/calls/active?token={TOKEN}")
|
||||
assert resp.status_code == 503 # auth accepted, handler 503s (no lifespan)
|
||||
|
||||
async def test_wrong_query_param_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get("/api/v1/calls/active?token=wrong")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_all_api_routers_protected(self, token_enabled, client):
|
||||
for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/",
|
||||
"/api/v1/routing/rules", "/api/v1/calls/history"):
|
||||
|
||||
Reference in New Issue
Block a user