feat(dashboard-auth): SPA WS auth — getWsTicket() + buildWsAuthParam()
Phase 5 task 5.3. The dashboard's three WS-using surfaces (ChatPage, gatewayClient, ChatSidebar) previously hardcoded ?token=<session>. In gated mode the server rejects that path; the SPA must mint a single-use ticket via POST /api/auth/ws-ticket and pass ?ticket= on the upgrade. web/src/lib/api.ts: adds getWsTicket() (POST /api/auth/ws-ticket with credentials: 'include') and buildWsAuthParam() — a helper that returns ['ticket', <minted>] in gated mode and ['token', <session>] in loopback. Window.__HERMES_AUTH_REQUIRED__ is read from the server-injected bootstrap script and toggles the path. Documented as the bridge from cookie auth (REST) to WS auth. web/src/pages/ChatPage.tsx: buildWsUrl() now takes an [authName, authValue] pair instead of a bare token. The WS construct is wrapped in an IIFE so the outer effect can stay synchronous (the cleanup returns the effect's disposer at top level). onDataDisposable + onResizeDisposable hoisted to `let` bindings the cleanup closes over. web/src/lib/gatewayClient.ts: connect() branches on window.__HERMES_AUTH_REQUIRED__ before opening /api/ws. Explicit token overrides win (test-only path); otherwise gated → fetch ticket, loopback → use injected session token. web/src/components/ChatSidebar.tsx: events-feed WS opens through the same IIFE pattern as ChatPage. The ws local is hoisted so the cleanup's ws?.close() works after the async mint resolves. Server side already injects window.__HERMES_AUTH_REQUIRED__ in _serve_index (Phase 3.5).
This commit is contained in:
@@ -25,6 +25,11 @@ declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_BASE_PATH__?: string;
|
||||
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
|
||||
* engaged (public bind, no ``--insecure``). Toggles the SPA's
|
||||
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
|
||||
* fetched via :func:`getWsTicket`. */
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
let _sessionToken: string | null = null;
|
||||
@@ -66,6 +71,43 @@ async function getSessionToken(): Promise<string> {
|
||||
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
|
||||
*
|
||||
* The dashboard's gated-mode WS auth (``hermes_cli.web_server._ws_auth_ok``)
|
||||
* rejects the legacy ``?token=<_SESSION_TOKEN>`` path and only accepts
|
||||
* ``?ticket=<minted>`` consumed against the in-memory ticket store. Browsers
|
||||
* can't set ``Authorization`` on a WS upgrade, so this round-trip via the
|
||||
* authenticated REST endpoint is the bridge from cookie auth to WS auth.
|
||||
*
|
||||
* Tickets are single-use and TTL=30s — every WS connect attempt must
|
||||
* fetch a fresh ticket.
|
||||
*/
|
||||
export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> {
|
||||
const res = await fetch(`${BASE}/api/auth/ws-ticket`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`/api/auth/ws-ticket: HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
|
||||
* connect. In gated mode mints a fresh single-use ticket; in loopback
|
||||
* mode returns the injected session token.
|
||||
*/
|
||||
export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
return ["ticket", ticket];
|
||||
}
|
||||
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
return ["token", token];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
getSessions: (limit = 20, offset = 0) =>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
||||
*/
|
||||
|
||||
import { HERMES_BASE_PATH } from "@/lib/api";
|
||||
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
|
||||
|
||||
export type GatewayEventName =
|
||||
| "gateway.ready"
|
||||
@@ -109,17 +109,32 @@ export class GatewayClient {
|
||||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!resolved) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
||||
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
||||
// Explicit ``token`` overrides the gate check (test-only path).
|
||||
let authParamName: string;
|
||||
let authParamValue: string;
|
||||
if (token) {
|
||||
authParamName = "token";
|
||||
authParamValue = token;
|
||||
} else if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
authParamName = "ticket";
|
||||
authParamValue = ticket;
|
||||
} else {
|
||||
authParamName = "token";
|
||||
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!authParamValue) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?token=${encodeURIComponent(resolved)}`,
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
@@ -233,5 +248,6 @@ export class GatewayClient {
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user