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
+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);
});
});
// ---------------------------------------------------------------------------