Reject WebSocket tokens minted before a password change

Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics.
This commit is contained in:
Maurice
2026-05-31 15:44:00 +02:00
parent 460694e335
commit eed9e8ce7c
4 changed files with 105 additions and 11 deletions
+4 -1
View File
@@ -1335,7 +1335,10 @@ export function deleteMcpToken(userId: number, tokenId: string): { error?: strin
// ---------------------------------------------------------------------------
export function createWsToken(userId: number): { error?: string; status?: number; token?: string } {
const token = createEphemeralToken(userId, 'ws');
// Bind the ws-token to the user's current password_version so a token minted
// before a password reset is rejected on connect (defence-in-depth session gate).
const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(userId) as { password_version?: number } | undefined)?.password_version ?? 0;
const token = createEphemeralToken(userId, 'ws', { pv });
if (!token) return { error: 'Service unavailable', status: 503 };
return { token };
}
+34 -2
View File
@@ -11,15 +11,31 @@ interface TokenEntry {
userId: number;
purpose: string;
expiresAt: number;
/**
* Snapshot of the user's `password_version` at mint time, used for the
* defence-in-depth session gate on WebSocket connects. `undefined` for
* tokens minted without a version (legacy/other purposes), which callers
* treat as version 0 — mirroring the JWT `pv` claim semantics.
*/
pv?: number;
}
export interface EphemeralTokenMeta {
/** Bind the token to the user's current password_version (session gate). */
pv?: number;
}
const store = new Map<string, TokenEntry>();
export function createEphemeralToken(userId: number, purpose: string): string | null {
export function createEphemeralToken(
userId: number,
purpose: string,
meta?: EphemeralTokenMeta,
): string | null {
if (store.size >= MAX_STORE_SIZE) return null;
const token = crypto.randomBytes(32).toString('hex');
const ttl = TTL[purpose] ?? 60_000;
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl });
store.set(token, { userId, purpose, expiresAt: Date.now() + ttl, pv: meta?.pv });
return token;
}
@@ -31,6 +47,22 @@ export function consumeEphemeralToken(token: string, purpose: string): number |
return entry.userId;
}
/**
* Like `consumeEphemeralToken`, but also returns the `password_version` the
* token was minted with. Used by the WebSocket handshake so a token issued
* before a password change can be rejected even within its short TTL.
*/
export function consumeEphemeralTokenWithMeta(
token: string,
purpose: string,
): { userId: number; pv?: number } | null {
const entry = store.get(token);
if (!entry) return null;
store.delete(token);
if (entry.purpose !== purpose || Date.now() > entry.expiresAt) return null;
return { userId: entry.userId, pv: entry.pv };
}
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
export function startTokenCleanup(): void {
+21 -8
View File
@@ -1,6 +1,6 @@
import { WebSocketServer, WebSocket } from 'ws';
import { db, canAccessTrip } from './db/database';
import { consumeEphemeralToken } from './services/ephemeralTokens';
import { consumeEphemeralTokenWithMeta } from './services/ephemeralTokens';
import { User } from './types';
import http from 'node:http';
@@ -69,20 +69,33 @@ function setupWebSocket(server: http.Server): void {
return;
}
const userId = consumeEphemeralToken(token, 'ws');
if (!userId) {
const consumed = consumeEphemeralTokenWithMeta(token, 'ws');
if (!consumed) {
nws.close(4001, 'Invalid or expired token');
return;
}
const { userId } = consumed;
let user: User | undefined;
user = db.prepare(
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
).get(userId) as User | undefined;
if (!user) {
let row: (User & { password_version?: number }) | undefined;
row = db.prepare(
'SELECT id, username, email, role, mfa_enabled, password_version FROM users WHERE id = ?'
).get(userId) as (User & { password_version?: number }) | undefined;
if (!row) {
nws.close(4001, 'User not found');
return;
}
// Session gate (defence-in-depth): reject a ws-token minted before a
// password change. Tokens carry the pv they were issued with; tokens
// minted without a pv (legacy) are treated as version 0, matching the
// JWT `pv` claim semantics in verifyJwtAndLoadUser.
const tokenPv = typeof consumed.pv === 'number' ? consumed.pv : 0;
const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
if (tokenPv !== currentPv) {
nws.close(4001, 'Invalid or expired token');
return;
}
// Don't leak password_version beyond the handshake.
const { password_version: _pv, ...user } = row;
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
if (requireMfa && !mfaOk) {
+46
View File
@@ -51,6 +51,7 @@ import { createUser, createTrip } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { setupWebSocket } from '../../src/websocket';
import { createEphemeralToken } from '../../src/services/ephemeralTokens';
import { createWsToken } from '../../src/services/authService';
let server: http.Server;
let wsUrl: string;
@@ -429,6 +430,51 @@ describe('WS auth edge cases', () => {
client.close();
}
});
it('WS-027 — ws-token minted before a password change is rejected (session gate)', async () => {
// createWsToken stamps the user's current password_version (0) into the token.
const { user } = createUser(testDb);
const result = createWsToken(user.id);
const token = result.token!;
// Simulate a password reset bumping the version AFTER the token was issued.
testDb.prepare('UPDATE users SET password_version = password_version + 1 WHERE id = ?').run(user.id);
const closeCode = await new Promise<number>((resolve) => {
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
ws.once('close', (code) => resolve(code));
ws.once('error', () => resolve(4001));
});
expect(closeCode).toBe(4001);
});
it('WS-028 — ws-token whose password_version still matches connects successfully', async () => {
const { user } = createUser(testDb);
// Bump the version first, THEN mint — the token captures the current pv.
testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id);
const result = createWsToken(user.id);
const client = await connectWs(result.token!);
try {
const msg = await client.next();
expect(msg.type).toBe('welcome');
} finally {
client.close();
}
});
it('WS-029 — legacy token without a pv is rejected once the user resets their password', async () => {
// Tokens minted via createEphemeralToken carry no pv (treated as version 0).
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
testDb.prepare('UPDATE users SET password_version = 1 WHERE id = ?').run(user.id);
const closeCode = await new Promise<number>((resolve) => {
const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`);
ws.once('close', (code) => resolve(code));
ws.once('error', () => resolve(4001));
});
expect(closeCode).toBe(4001);
});
});
// ---------------------------------------------------------------------------