vault: session-gated notifications WS + lifecycle cleanup
- token-store: shared "give me a valid Vaultwarden access token" (proactive refresh) used by both the HTTP proxy and the WS; router refactored onto it. - notifications WS: validates the platform session in `open` (deferred, owner only), injects the stored Vaultwarden token into the upstream, and buffers client frames during the async setup so the SignalR handshake isn't dropped. The device connects with its platform JWT (?access_token=), never a vault one. - lifecycle: logout drops the vault token set (keeps the protector); distress (/auth/revoke) and panic wipe both token set and protector, forcing a one-time master-password re-setup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,48 +1,91 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { getVaultServerWsUrl } from './sidecar-server';
|
||||
import { verify } from '../../jwt';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { isOriginAllowed } from '../../_middlewares';
|
||||
import { getVaultServerWsUrl } from './sidecar-server';
|
||||
import { getValidAccessToken } from './token-store';
|
||||
|
||||
// Platform side of the Bitwarden notifications WebSocket: origin-gate the client upgrade, then pipe it to
|
||||
// the officer-vault sidecar (which in turn pipes to Vaultwarden). Dumb pipe: text + binary frames both
|
||||
// ways, no inspection. The platform holds NO Vaultwarden knowledge — only the sidecar's loopback address.
|
||||
// SignalR's HTTP long-poll fallback does NOT arrive here — it rides the HTTP proxy (vaultRouter).
|
||||
// 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';
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
vaultWsPath: string; // subpath + query after /api/vault, e.g. /notifications/hub?access_token=…
|
||||
platformToken: string; // the platform JWT the device presented on the upgrade
|
||||
vaultWsPath: string; // path + query after /api/vault, e.g. /notifications/hub?access_token=<jwt>
|
||||
vaultWsProtocol?: string; // requested Sec-WebSocket-Protocol, forwarded to the sidecar
|
||||
};
|
||||
|
||||
type UpstreamState = { ws: WebSocket; queue: (string | Uint8Array<ArrayBuffer>)[]; ready: boolean };
|
||||
type UpstreamState = {
|
||||
ws: WebSocket | null;
|
||||
queue: (string | Uint8Array<ArrayBuffer>)[];
|
||||
ready: boolean;
|
||||
closed: boolean;
|
||||
};
|
||||
const upstreams = new Map<ServerWebSocket<VaultWSData>, 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<ArrayBuffer> =>
|
||||
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
|
||||
|
||||
function closeClient(ws: ServerWebSocket<VaultWSData>, 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 = {
|
||||
open(ws: ServerWebSocket<VaultWSData>) {
|
||||
async open(ws: ServerWebSocket<VaultWSData>) {
|
||||
// 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 verify(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) {
|
||||
ws.close(1011, 'Vault sidecar not available');
|
||||
return;
|
||||
}
|
||||
if (!wsBase) return closeClient(ws, state, 1011, 'Vault sidecar not available');
|
||||
if (state.closed) return;
|
||||
|
||||
const url = `${wsBase}${ws.data.vaultWsPath}`;
|
||||
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';
|
||||
|
||||
const state: UpstreamState = { ws: upstream, queue: [], ready: false };
|
||||
upstreams.set(ws, state);
|
||||
state.ws = upstream;
|
||||
|
||||
upstream.addEventListener('open', () => {
|
||||
state.ready = true;
|
||||
@@ -77,14 +120,15 @@ export const vaultWebsocket = {
|
||||
const state = upstreams.get(ws);
|
||||
if (!state) return;
|
||||
const payload = asPayload(raw);
|
||||
if (state.ready) state.ws.send(payload);
|
||||
else state.queue.push(payload); // buffer until the sidecar socket opens
|
||||
if (state.ready && state.ws) state.ws.send(payload);
|
||||
else state.queue.push(payload); // buffer until the upstream socket opens
|
||||
},
|
||||
close(ws: ServerWebSocket<VaultWSData>) {
|
||||
const state = upstreams.get(ws);
|
||||
if (state) {
|
||||
state.closed = true;
|
||||
try {
|
||||
state.ws.close();
|
||||
state.ws?.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
@@ -96,21 +140,21 @@ export const vaultWebsocket = {
|
||||
|
||||
const PREFIX = '/api/vault';
|
||||
|
||||
// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated: only an allow-listed origin
|
||||
// (the OffVault app in prod; anything in dev) may connect — mirroring the HTTP origin gate. The Bitwarden
|
||||
// access_token rides the query string and is forwarded untouched; we do NOT verify it as a platform JWT
|
||||
// (it isn't one), matching the "don't inject platform session auth" caveat.
|
||||
// Serve-level upgrade for /api/vault/notifications/* WebSockets. Origin-gated; the platform JWT rides the
|
||||
// query (?access_token= for SignalR, or ?token=). The session is validated in `open` (deferred). The
|
||||
// device never sends a Vaultwarden token — we inject the stored one upstream.
|
||||
export function upgradeVaultWs(req: Request, server: any): Response | undefined {
|
||||
const origin = req.headers.get('origin') ?? undefined;
|
||||
const host = req.headers.get('host') ?? undefined;
|
||||
if (!isOriginAllowed(origin, host)) return new Response('Forbidden', { status: 403 });
|
||||
|
||||
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',
|
||||
userId: 0,
|
||||
email: '',
|
||||
username: '',
|
||||
platformToken,
|
||||
vaultWsPath: url.pathname.slice(PREFIX.length) + url.search,
|
||||
vaultWsProtocol: req.headers.get('sec-websocket-protocol') ?? undefined,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user