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:
@@ -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
@@ -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) });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user