import type { ServerWebSocket } from 'bun'; import { resolveAuthToken } from '../../auth-token'; import { isTokenBlacklisted } from 'officerdb'; import { isSuperAdmin } from '../../super-admin'; import { getVaultServerWsUrl } from './sidecar-server'; import { getValidAccessToken } from './token-store'; // Platform side of the Bitwarden notifications WebSocket. The device connects with its platform JWT (via // ?access_token=, how SignalR carries the token); we validate that session in `open`, then pipe the socket // to the officer-vault sidecar with the stored Vaultwarden token injected — the device never holds it. // Dumb pipe: text + binary frames both ways, no inspection. SignalR's HTTP long-poll fallback rides the // HTTP proxy (vaultRouter) instead. export type VaultWSData = { provider: 'vault'; platformToken: string; // the platform JWT the device presented on the upgrade vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token= vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar }; type UpstreamState = { ws: WebSocket | null; queue: (string | Uint8Array)[]; ready: boolean; closed: boolean; }; const upstreams = new Map, UpstreamState>(); // Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is. const asPayload = (raw: string | Buffer): string | Uint8Array => typeof raw === 'string' ? raw : (raw as Uint8Array); function closeClient(ws: ServerWebSocket, state: UpstreamState, code: number, reason: string) { state.closed = true; upstreams.delete(ws); try { ws.close(code, reason); } catch { /* already closed */ } } // Swap the platform JWT in the path for the Vaultwarden access token before dialing upstream. function injectToken(wsBase: string, vaultWsPath: string, vaultToken: string): string { const qIdx = vaultWsPath.indexOf('?'); const path = qIdx >= 0 ? vaultWsPath.slice(0, qIdx) : vaultWsPath; const params = new URLSearchParams(qIdx >= 0 ? vaultWsPath.slice(qIdx + 1) : ''); params.delete('token'); params.set('access_token', vaultToken); return `${wsBase}${path}?${params.toString()}`; } export const vaultWebsocket = { async open(ws: ServerWebSocket) { // Register state synchronously so frames sent during the async setup below are buffered, not dropped. const state: UpstreamState = { ws: null, queue: [], ready: false, closed: false }; upstreams.set(ws, state); // Deferred session validation (Bun requires the upgrade itself to be synchronous). let userId: number; try { const payload = await resolveAuthToken(ws.data.platformToken); if (!payload?.id) return closeClient(ws, state, 4001, 'Unauthorized'); if (payload.jti && (await isTokenBlacklisted(payload.jti))) return closeClient(ws, state, 4001, 'Unauthorized'); if (!(await isSuperAdmin(payload))) return closeClient(ws, state, 4001, 'Forbidden'); userId = payload.id; } catch { return closeClient(ws, state, 4001, 'Unauthorized'); } if (state.closed) return; const vaultToken = await getValidAccessToken(userId); if (!vaultToken) return closeClient(ws, state, 4001, 'No vault session'); const wsBase = getVaultServerWsUrl(); if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available'); if (state.closed) return; const protocols = ws.data.vaultWsProtocol ? ws.data.vaultWsProtocol .split(',') .map((s) => s.trim()) .filter(Boolean) : undefined; const url = injectToken(wsBase, ws.data.vaultWsPath, vaultToken); const upstream = protocols?.length ? new WebSocket(url, protocols) : new WebSocket(url); upstream.binaryType = 'arraybuffer'; state.ws = upstream; upstream.addEventListener('open', () => { state.ready = true; for (const m of state.queue) upstream.send(m); state.queue.length = 0; }); upstream.addEventListener('message', (ev) => { try { ws.send(ev.data as string | ArrayBuffer); } catch { /* client gone */ } }); upstream.addEventListener('close', (ev) => { upstreams.delete(ws); try { ws.close(ev.code || 1000, ev.reason || ''); } catch { /* already closed */ } }); upstream.addEventListener('error', () => { upstreams.delete(ws); try { ws.close(1011, 'upstream error'); } catch { /* already closed */ } }); }, message(ws: ServerWebSocket, raw: string | Buffer) { const state = upstreams.get(ws); if (!state) return; const payload = asPayload(raw); if (state.ready && state.ws) state.ws.send(payload); else state.queue.push(payload); // buffer until the upstream socket opens }, close(ws: ServerWebSocket) { const state = upstreams.get(ws); if (state) { state.closed = true; try { state.ws?.close(); } catch { /* already closed */ } upstreams.delete(ws); } }, drain() {}, }; const PREFIX = '/api/vault'; // Serve-level upgrade for /api/vault/notifications/* WebSockets. The platform JWT rides the query // (?access_token= for SignalR, or ?token=) and the session is validated in `open` (deferred). The device // never sends a Vaultwarden token — we inject the stored one upstream. // // There was an isOriginAllowed gate here until 2026-08-13, removed with the rest of origin validation. // It had defaulted to allow-everything, so it refused nothing on a real install. export function upgradeVaultWs(req: Request, server: any): Response | undefined { const url = new URL(req.url); const platformToken = url.searchParams.get('access_token') || url.searchParams.get('token') || ''; if (!platformToken) return new Response('Unauthorized', { status: 401 }); const data: VaultWSData = { provider: 'vault', platformToken, vaultWsPath: url.pathname.slice(PREFIX.length) + url.search, vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined, }; const ok = server.upgrade(req, { data }); if (!ok) return new Response('Upgrade failed', { status: 500 }); return undefined; }