[eric] security: lock down the local websocket + http api with a per-install auth token

This commit is contained in:
ciregenz
2026-04-25 02:26:13 -07:00
parent f5cc3a36fb
commit 2379dd92be
13 changed files with 595 additions and 32 deletions
+76
View File
@@ -4,3 +4,79 @@ const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
// Per-install auth token. Fetched from Electron's main process via the
// preload contextBridge. We cache it after first resolution so every
// API/WS call is synchronous. On Electron hot-reload the token rotates;
// call `refreshAuthToken()` from a 4401 WS handler to pick up a new
// one without a full page reload.
let _authTokenCache: string = '';
let _authTokenPromise: Promise<string> | null = null;
export function getAuthToken(): string {
return _authTokenCache;
}
export async function refreshAuthToken(): Promise<string> {
const ow = (window as any).openswarm;
if (ow && typeof ow.getAuthToken === 'function') {
try {
const tok = await ow.getAuthToken();
_authTokenCache = typeof tok === 'string' ? tok : '';
} catch {
_authTokenCache = '';
}
}
return _authTokenCache;
}
// Resolve-once helper: the first call kicks off the IPC request; any
// concurrent calls reuse the same promise. Frontend bootstrap awaits
// this before the first API call so the token is ready.
export function ensureAuthToken(): Promise<string> {
if (_authTokenPromise) return _authTokenPromise;
_authTokenPromise = refreshAuthToken();
return _authTokenPromise;
}
// Install a global fetch interceptor so every fetch(API_BASE + ...)
// call site gets the Authorization header without touching each site.
// Covers the analytics, settings, agents, dashboards, etc. fetches.
// Only applies to requests that target our own API_BASE — pass-through
// for every other URL (3rd-party APIs, asset CDNs, etc.).
function _installAuthFetchInterceptor() {
if ((window as any).__OPENSWARM_FETCH_PATCHED__) return;
(window as any).__OPENSWARM_FETCH_PATCHED__ = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
try {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
// Only attach token for our own API. Everything else flows through.
const isOurApi = url.startsWith(API_BASE) || url.startsWith(`http://${host}:${port}/`);
if (!isOurApi) return originalFetch(input, init);
// Don't override an explicit Authorization the caller already set.
const existingHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (existingHeaders.has('Authorization') || existingHeaders.has('authorization')) {
return originalFetch(input, init);
}
const token = _authTokenCache || (await ensureAuthToken());
if (!token) return originalFetch(input, init);
existingHeaders.set('Authorization', `Bearer ${token}`);
const newInit: RequestInit = { ...(init ?? {}), headers: existingHeaders };
return originalFetch(input, newInit);
} catch {
return originalFetch(input, init);
}
};
}
// Call immediately on module load — config.ts is imported by the main
// entry point, so this runs before any component-level fetch.
_installAuthFetchInterceptor();
// Kick off token resolution in the background so it's warm by the
// time the first request goes out.
ensureAuthToken();
+29 -2
View File
@@ -17,6 +17,15 @@ import {
trackAgentNotification,
} from '../state/agentsSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
import { getAuthToken } from '../config';
// Thin wrapper around getAuthToken so the connect() call site stays
// synchronous. If the token isn't cached yet, returns '' and the WS
// handshake will 4401 — onclose catches that and refreshes the token
// before the next reconnect.
const _getAuthTokenSafe = (): string => {
try { return getAuthToken() || ''; } catch { return ''; }
};
type WSEvent = {
event: string;
@@ -104,7 +113,19 @@ class WebSocketManager {
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.ws = new WebSocket(this.url);
// Append our per-install auth token to the URL. The backend's WS
// handshake validates this before accepting; without it, any
// webpage loaded on the same machine could open a WS and read
// agent traffic. See backend/auth.py + main.py:_ws_auth_ok.
// Token is fetched async from Electron's preload, but we cache it
// after first resolution. If it isn't cached yet, `getAuthToken()`
// returns '' and the connection will be rejected — the
// onclose handler below retries, by which time the token is
// usually loaded.
const token = _getAuthTokenSafe();
const sep = this.url.includes('?') ? '&' : '?';
const urlWithToken = token ? `${this.url}${sep}token=${encodeURIComponent(token)}` : this.url;
this.ws = new WebSocket(urlWithToken);
this.ws.onopen = () => {
this.reconnectDelay = 1000;
@@ -119,7 +140,13 @@ class WebSocketManager {
}
};
this.ws.onclose = () => {
this.ws.onclose = (ev) => {
// 4401 = our backend's auth-failure code. Happens on stale token
// after backend restart (dev hot-reload). Re-fetch from Electron
// IPC before retrying.
if (ev && ev.code === 4401) {
import('@/shared/config').then(mod => mod.refreshAuthToken().catch(() => {}));
}
this.scheduleReconnect();
};