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
+1
View File
@@ -105,6 +105,7 @@ export type {
export {
getVaultTokens,
setVaultTokens,
updateVaultAccess,
clearVaultTokens,
getVaultUnlockKey,
setVaultUnlockKey,
@@ -11,6 +11,7 @@ export type VaultTokenSet = {
refreshToken: string;
expiresAt: Date | null;
deviceIdentifier: string | null;
clientId: string | null;
};
/** The owner's brokered Vaultwarden token set (decrypted), or null if none is stored. */
@@ -22,6 +23,7 @@ export async function getVaultTokens(userId: number): Promise<VaultTokenSet | nu
refreshToken: decryptSecret(row.refreshToken),
expiresAt: row.expiresAt,
deviceIdentifier: row.deviceIdentifier,
clientId: row.clientId,
};
}
@@ -33,6 +35,7 @@ export async function setVaultTokens(userId: number, t: VaultTokenSet): Promise<
refreshToken: encryptSecret(t.refreshToken),
expiresAt: t.expiresAt,
deviceIdentifier: t.deviceIdentifier,
clientId: t.clientId,
updatedAt: new Date(),
};
await db
@@ -45,11 +48,30 @@ export async function setVaultTokens(userId: number, t: VaultTokenSet): Promise<
refreshToken: values.refreshToken,
expiresAt: values.expiresAt,
deviceIdentifier: values.deviceIdentifier,
clientId: values.clientId,
updatedAt: values.updatedAt,
},
});
}
/** Update only the token pair + expiry after a refresh (keeps device/client). */
export async function updateVaultAccess(
userId: number,
accessToken: string,
refreshToken: string,
expiresAt: Date | null,
): Promise<void> {
await db
.update(vaultTokens)
.set({
accessToken: encryptSecret(accessToken),
refreshToken: encryptSecret(refreshToken),
expiresAt,
updatedAt: new Date(),
})
.where(eq(vaultTokens.userId, userId));
}
/** Drop the token set (platform logout / distress / panic). */
export async function clearVaultTokens(userId: number): Promise<void> {
await db.delete(vaultTokens).where(eq(vaultTokens.userId, userId));
@@ -15,6 +15,7 @@ export const vaultTokens = pgTable('vault_tokens', {
refreshToken: text('refresh_token').notNull(), // encrypted
expiresAt: timestamp('expires_at', { withTimezone: true }),
deviceIdentifier: text('device_identifier'), // plaintext; the last device that brokered a login
clientId: text('client_id'), // plaintext; the connect/token client_id, needed to refresh
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});