diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 1729a846..c545981b 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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 }; } diff --git a/server/src/services/ephemeralTokens.ts b/server/src/services/ephemeralTokens.ts index dc631324..aed77d61 100644 --- a/server/src/services/ephemeralTokens.ts +++ b/server/src/services/ephemeralTokens.ts @@ -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(); -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 | null = null; export function startTokenCleanup(): void { diff --git a/server/src/websocket.ts b/server/src/websocket.ts index 96e98712..595e5ad9 100644 --- a/server/src/websocket.ts +++ b/server/src/websocket.ts @@ -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) { diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts index 24b60910..917777c6 100644 --- a/server/tests/websocket/connection.test.ts +++ b/server/tests/websocket/connection.test.ts @@ -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((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((resolve) => { + const ws = new WebSocket(`${wsUrl}?token=${encodeURIComponent(token)}`); + ws.once('close', (code) => resolve(code)); + ws.once('error', () => resolve(4001)); + }); + expect(closeCode).toBe(4001); + }); }); // ---------------------------------------------------------------------------