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:
2026-07-29 02:44:55 +00:00
co-authored by Claude Opus 4.8
parent ccc86cca6d
commit aae0fbd0ea
6 changed files with 138 additions and 69 deletions
+9 -1
View File
@@ -1,12 +1,20 @@
import type { Handler } from 'hono';
import { clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { triggerLockdown } from './panic';
import { clientIp } from './client-ip';
// Trigger the panic lockdown. Authenticated (Bearer token) — no password in the body. Once tripped,
// all logins and existing sessions are refused until the server is manually restarted.
export const panicHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { email?: string } | undefined;
const user = ctx.get('user') as { id?: number; email?: string } | undefined;
const origin = ctx.get('origin') as string | undefined;
triggerLockdown(`/panic (user=${user?.email || '?'}, origin=${origin || '-'}, ip=${clientIp(ctx)})`);
// Panic wipes the vault session AND the protector key (like distress).
if (user?.id) {
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
}
return ctx.json({ ok: true });
};
+8 -2
View File
@@ -1,10 +1,10 @@
import type { Handler } from 'hono';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens, clearVaultUnlockKey } from 'officerdb';
import { clientIp } from './client-ip';
// Distress: blacklist the current token (same as signout) but log it as a security event.
export const revokeHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { jti: string; exp: number; email?: string };
const user = ctx.get('user') as { id: number; jti: string; exp: number; email?: string };
const origin = ctx.get('origin') as string | undefined;
console.warn(
@@ -12,6 +12,12 @@ export const revokeHandler: Handler = async (ctx) => {
);
await blacklistToken(user.jti, user.exp);
// Distress wipes the vault session AND the stored protector key — forces a one-time master-password
// re-setup, the correct "someone got in" behavior.
clearVaultTokens(user.id).catch(() => {});
clearVaultUnlockKey(user.id).catch(() => {});
cleanupExpiredTokens().catch(() => {});
return ctx.json({ ok: true });
+5 -2
View File
@@ -1,11 +1,14 @@
import type { Handler } from 'hono';
import { blacklistToken, cleanupExpiredTokens } from 'officerdb';
import { blacklistToken, cleanupExpiredTokens, clearVaultTokens } from 'officerdb';
export const signoutHandler: Handler = async (ctx) => {
const user = ctx.get('user') as { jti: string; exp: number };
const user = ctx.get('user') as { id: number; jti: string; exp: number };
await blacklistToken(user.jti, user.exp);
// Drop the brokered vault session on logout (the protector key stays, so re-login is frictionless).
clearVaultTokens(user.id).catch(() => {});
// Opportunistic cleanup of expired tokens (non-blocking)
cleanupExpiredTokens().catch(() => {});
+9 -35
View File
@@ -5,15 +5,9 @@ import { isSuperAdmin } from '../../super-admin';
import * as errors from '../../custom-errors';
import { getVaultServerUrl } from './sidecar-server';
import { stripHopByHop, redactPath } from './proxy-util';
import { passwordGrant, refreshGrant, pick, expiryFrom, type PasswordGrantInput } from './broker';
import {
getVaultTokens,
setVaultTokens,
updateVaultAccess,
getVaultUnlockKey,
setVaultUnlockKey,
type VaultTokenSet,
} from 'officerdb';
import { passwordGrant, pick, expiryFrom, type PasswordGrantInput } from './broker';
import { getValidAccessToken, refreshAccess } from './token-store';
import { getVaultTokens, setVaultTokens, getVaultUnlockKey, setVaultUnlockKey } from 'officerdb';
// Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid
// platform session authorizes vault access. This router:
@@ -26,7 +20,6 @@ import {
export const vaultRouter = createRouter();
const PREFIX = '/api/vault';
const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401
// Owner-only. The global backstop already blocks non-owner tokens from /api/vault; this is explicit
// defense-in-depth and gives a clear error.
@@ -39,22 +32,6 @@ vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's orig
vaultRouter.use(userMiddleware); // valid platform session (Authorization: Bearer <platform JWT> or ?token=)
vaultRouter.use(ownerGate);
// Renew the access token from the stored refresh token; persist and return the new access token, or null.
async function refreshAccess(userId: number, ts: VaultTokenSet): Promise<string | null> {
const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile');
if (!r.ok) return null;
const access = pick(r.data, 'access_token');
const refresh = pick(r.data, 'refresh_token');
if (typeof access !== 'string') return null;
await updateVaultAccess(
userId,
access,
typeof refresh === 'string' ? refresh : ts.refreshToken,
expiryFrom(r.data, Date.now()),
);
return access;
}
// ── Native endpoints (registered before the catch-all) ──
// Reachability probe (owner-gated): forwards to the sidecar's /_health, which probes Vaultwarden's /alive.
@@ -149,13 +126,9 @@ vaultRouter.all('/*', async (ctx) => {
const hasBody = method !== 'GET' && method !== 'HEAD';
const started = Date.now();
// Resolve the Vaultwarden token to inject (proactively refreshed if near expiry). No token yet →
// forward without one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401.
const tokenSet = await getVaultTokens(user.id);
let accessToken = tokenSet?.accessToken ?? null;
if (tokenSet?.expiresAt && tokenSet.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) {
accessToken = (await refreshAccess(user.id, tokenSet)) ?? accessToken;
}
// Inject a valid Vaultwarden token (proactively refreshed near expiry). No token yet → forward without
// one: unauthenticated endpoints (prelogin) work; authed ones get Vaultwarden's 401.
const accessToken = await getValidAccessToken(user.id);
const headers = stripHopByHop(ctx.req.raw.headers);
headers.delete('authorization'); // drop the platform JWT
@@ -176,8 +149,9 @@ vaultRouter.all('/*', async (ctx) => {
}
// A 401 on a replayable (no-body) request → token likely just expired/revoked: refresh once and retry.
if (upstream.status === 401 && !hasBody && tokenSet) {
const fresh = await refreshAccess(user.id, tokenSet);
if (upstream.status === 401 && !hasBody) {
const ts = await getVaultTokens(user.id);
const fresh = ts ? await refreshAccess(user.id, ts) : null;
if (fresh) {
headers.set('authorization', `Bearer ${fresh}`);
upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream);
+34
View File
@@ -0,0 +1,34 @@
import { getVaultTokens, updateVaultAccess, type VaultTokenSet } from 'officerdb';
import { refreshGrant, pick, expiryFrom } from './broker';
// Read-side of the brokered Vaultwarden token: hand out a currently-valid access token, refreshing it
// server-side (with the stored refresh token) when it's near expiry. Shared by the HTTP proxy and the
// notifications-WS proxy so both inject a fresh token. The device never sees any of this.
const REFRESH_SKEW_MS = 60_000; // refresh a bit before expiry to avoid racing a 401
/** Renew the access token from the stored refresh token; persist and return it, or null on failure. */
export async function refreshAccess(userId: number, ts: VaultTokenSet): Promise<string | null> {
const r = await refreshGrant(ts.refreshToken, ts.clientId ?? 'mobile');
if (!r.ok) return null;
const access = pick(r.data, 'access_token');
const refresh = pick(r.data, 'refresh_token');
if (typeof access !== 'string') return null;
await updateVaultAccess(
userId,
access,
typeof refresh === 'string' ? refresh : ts.refreshToken,
expiryFrom(r.data, Date.now()),
);
return access;
}
/** A currently-valid Vaultwarden access token for the owner, or null if none is stored. */
export async function getValidAccessToken(userId: number): Promise<string | null> {
const ts = await getVaultTokens(userId);
if (!ts) return null;
if (ts.expiresAt && ts.expiresAt.getTime() < Date.now() + REFRESH_SKEW_MS) {
return (await refreshAccess(userId, ts)) ?? ts.accessToken;
}
return ts.accessToken;
}
+73 -29
View File
@@ -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,
};