vault: session-gated auth-injecting proxy + token broker

Implements the platform half of VAULT_AUTH_SPEC.md. /api/vault is now gated on
an owner platform session (userMiddleware, no bodyParser → streaming preserved)
and origin-scoped as before; the device holds no Vaultwarden token.

- POST /session/login {email, authHash, kdf, device*} → broker calls Vaultwarden
  /identity/connect/token via the sidecar, stores the encrypted token set tied to
  the owner, and returns {protectedUserKey, privateKey, kdf} (ciphertext to us).
- GET/PUT /unlock-key → store/release the Officer-app protector key (owner only).
- Catch-all proxy swaps the incoming platform JWT for the stored Vaultwarden
  access token, proactively refreshes near expiry, and retries once on a 401 for
  replayable requests. Bodies are never parsed.

client_id column added to vault_tokens (needed to refresh). Broker error text is
read across Vaultwarden's message/errorModel/error fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 02:40:21 +00:00
co-authored by Claude Opus 4.8
parent 192337cd1b
commit ccc86cca6d
5 changed files with 261 additions and 18 deletions
+79
View File
@@ -0,0 +1,79 @@
import { getVaultServerUrl } from './sidecar-server';
// Vaultwarden token broker. The platform (not the device) obtains and refreshes the Vaultwarden token via
// the sidecar's `/identity/connect/token`. We deal in the OAuth-style form grants only; the master
// password never appears here — the client sends a pre-derived `authHash` (Bitwarden's master password
// hash), exactly what Vaultwarden itself checks.
export type ConnectResult = { ok: true; data: Record<string, unknown> } | { ok: false; status: number; error: string };
async function postConnectToken(form: URLSearchParams): Promise<ConnectResult> {
const base = getVaultServerUrl();
if (!base) return { ok: false, status: 503, error: 'vault sidecar unavailable' };
let res: Response;
try {
res = await fetch(`${base}/identity/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
signal: AbortSignal.timeout(30_000),
});
} catch {
return { ok: false, status: 502, error: 'vault upstream unreachable' };
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
// Vaultwarden puts the human message in different places (error_description/error are often empty on a
// bad login; the real text is in `message` or `errorModel.message`). Check them all.
const errorModel = data.errorModel as Record<string, unknown> | undefined;
const candidates = [data.error_description, errorModel?.message, data.message, data.error];
const desc = candidates.find((c): c is string => typeof c === 'string' && c.length > 0) ?? 'vault login failed';
return { ok: false, status: res.status, error: desc };
}
return { ok: true, data };
}
export type PasswordGrantInput = {
email: string;
authHash: string;
clientId: string;
deviceIdentifier: string;
deviceType: string;
deviceName: string;
};
/** Owner login: exchange the client-derived auth hash for a Vaultwarden token set. */
export function passwordGrant(p: PasswordGrantInput): Promise<ConnectResult> {
const form = new URLSearchParams();
form.set('grant_type', 'password');
form.set('username', p.email);
form.set('password', p.authHash);
form.set('scope', 'api offline_access'); // offline_access → a refresh token comes back
form.set('client_id', p.clientId);
form.set('deviceIdentifier', p.deviceIdentifier);
form.set('deviceType', p.deviceType);
form.set('deviceName', p.deviceName);
return postConnectToken(form);
}
/** Renew the access token from the stored refresh token (no password needed). */
export function refreshGrant(refreshToken: string, clientId: string): Promise<ConnectResult> {
const form = new URLSearchParams();
form.set('grant_type', 'refresh_token');
form.set('refresh_token', refreshToken);
form.set('client_id', clientId);
return postConnectToken(form);
}
// Vaultwarden mixes PascalCase and camelCase field names across versions — read a field case-insensitively.
export function pick(data: Record<string, unknown>, name: string): unknown {
const lower = name.charAt(0).toLowerCase() + name.slice(1);
const upper = name.charAt(0).toUpperCase() + name.slice(1);
return data[name] ?? data[lower] ?? data[upper];
}
/** Compute an absolute expiry from a token response's `expires_in` (seconds), if present. */
export function expiryFrom(data: Record<string, unknown>, now: number): Date | null {
const secs = data.expires_in;
return typeof secs === 'number' && Number.isFinite(secs) ? new Date(now + secs * 1000) : null;
}
+158 -18
View File
@@ -1,41 +1,171 @@
import type { MiddlewareHandler } from 'hono';
import { createRouter } from '../../create-router';
import { originMiddleware, userMiddleware } from '../../_middlewares';
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';
// Thin, origin-gated forwarder for /api/vault/* → the officer-vault sidecar, which OWNS the transparent
// reverse-proxy to Vaultwarden (its URL, paths, and notifications WebSocket all live there). The platform's
// only jobs here are AUTH/ORIGIN (via the global originScopeMiddleware — OFFICER_VAULT_ORIGIN → /api/vault)
// and faithful FORWARDING. We never touch bodies: method/path/query/headers/status and both body streams
// pass through verbatim.
//
// Mounted TOP-LEVEL at /api/vault (see hono.ts) — NOT under the protected router: the Bitwarden client
// carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The
// notifications WebSocket is upgraded at the serve level (server.tsx) and likewise forwarded to the sidecar.
// Platform side of Officer Vault (VAULT_AUTH_SPEC.md). The device holds NO Vaultwarden token; a valid
// platform session authorizes vault access. This router:
// • gates every /api/vault/* on an owner platform session (userMiddleware, no bodyParser → streaming),
// • serves the native broker/unlock-key endpoints,
// • proxies the rest to the officer-vault sidecar, swapping the incoming platform JWT for the stored
// Vaultwarden token. Bodies are never parsed/decrypted; only the Authorization header is rewritten.
// The origin scoping (OFFICER_VAULT_ORIGIN → /api/vault) is enforced globally by originScopeMiddleware.
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.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Vault is owner-only');
return next();
};
vaultRouter.use(originMiddleware); // set ctx 'origin' for userMiddleware's origin check
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.
vaultRouter.get('/_health', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.json({ ok: false, error: 'vault sidecar unavailable' }, 503);
try {
const r = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(5000) });
const body = await r.json().catch(() => ({ ok: r.ok }));
return Response.json(body, { status: r.status });
} catch {
return ctx.json({ ok: false, error: 'vault sidecar unreachable' }, 502);
}
});
// Token broker — exchange the client-derived auth hash for a Vaultwarden token set (stored server-side),
// and return the encrypted user-key material the client needs to unlock on-device.
vaultRouter.post('/session/login', async (ctx) => {
const user = ctx.get('user');
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
const str = (v: unknown) => (typeof v === 'string' ? v : '');
const email = str(b.email);
const authHash = str(b.authHash);
if (!email || !authHash) return ctx.json({ ok: false, error: 'email and authHash required' }, 400);
const clientId = str(b.clientId) || 'mobile';
const input: PasswordGrantInput = {
email,
authHash,
clientId,
deviceIdentifier: str(b.deviceIdentifier),
deviceType: b.deviceType != null ? String(b.deviceType) : '',
deviceName: str(b.deviceName),
};
const result = await passwordGrant(input);
if (!result.ok) return Response.json({ ok: false, error: result.error }, { status: result.status });
const d = result.data;
const access = pick(d, 'access_token');
const refresh = pick(d, 'refresh_token');
if (typeof access !== 'string' || typeof refresh !== 'string') {
return ctx.json({ ok: false, error: 'vault returned no token' }, 502);
}
await setVaultTokens(user.id, {
accessToken: access,
refreshToken: refresh,
expiresAt: expiryFrom(d, Date.now()),
deviceIdentifier: input.deviceIdentifier || null,
clientId,
});
// Ciphertext to us — passed straight to the client, which decrypts on-device with the master key.
return ctx.json({
ok: true,
protectedUserKey: pick(d, 'Key') ?? null,
privateKey: pick(d, 'PrivateKey') ?? null,
kdf: {
kdf: pick(d, 'Kdf') ?? null,
kdfIterations: pick(d, 'KdfIterations') ?? null,
kdfMemory: pick(d, 'KdfMemory') ?? null,
kdfParallelism: pick(d, 'KdfParallelism') ?? null,
},
});
});
// Officer-app unlock path: store / release the platform-held protector key (owner session only).
vaultRouter.put('/unlock-key', async (ctx) => {
const user = ctx.get('user');
const b = (await ctx.req.json().catch(() => ({}))) as Record<string, unknown>;
if (typeof b.wrappedKey !== 'string' || !b.wrappedKey) return ctx.json({ error: 'wrappedKey required' }, 400);
await setVaultUnlockKey(user.id, b.wrappedKey);
return ctx.json({ ok: true });
});
vaultRouter.get('/unlock-key', async (ctx) => {
const user = ctx.get('user');
const wrappedKey = await getVaultUnlockKey(user.id);
if (!wrappedKey) return ctx.json({ error: 'no unlock key stored' }, 404);
return ctx.json({ wrappedKey });
});
// ── Token-injecting proxy → sidecar → Vaultwarden ──
vaultRouter.all('/*', async (ctx) => {
const base = getVaultServerUrl();
if (!base) return ctx.text('Vault sidecar not available', 503);
const user = ctx.get('user');
const url = new URL(ctx.req.url);
// Strip the mount prefix so the sidecar sees Vaultwarden-root paths (/identity, /api, /notifications…).
const subpath = url.pathname.slice(PREFIX.length) || '/';
const target = `${base}${subpath}${url.search}`;
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
const started = Date.now();
// Bun/undici require half-duplex to stream a request body straight through (attachments can be large).
const init: RequestInit & { duplex?: 'half' } = {
method,
headers: stripHopByHop(ctx.req.raw.headers),
body: hasBody ? ctx.req.raw.body : undefined,
redirect: 'manual',
};
if (hasBody) init.duplex = 'half';
// 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;
}
const headers = stripHopByHop(ctx.req.raw.headers);
headers.delete('authorization'); // drop the platform JWT
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);
const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' };
if (hasBody) {
init.body = ctx.req.raw.body;
init.duplex = 'half';
}
let upstream: Response;
try {
@@ -45,5 +175,15 @@ vaultRouter.all('/*', async (ctx) => {
return ctx.text('Vault sidecar unreachable', 502);
}
// 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 (fresh) {
headers.set('authorization', `Bearer ${fresh}`);
upstream = await fetch(target, { method, headers, redirect: 'manual' }).catch(() => upstream);
}
}
console.log(`[vault] ${method} ${redactPath(subpath)} -> ${upstream.status} ${Date.now() - started}ms`);
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
});