From a96e8922b688565fcded0946b990901996622f8d Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 23:45:57 +0000 Subject: [PATCH] feat: add JWT blacklist for logout with TTL cleanup blacklistJwt() adds token to in-memory blacklist until its natural expiry. verifyJwt() checks blacklist before signature verification. Cleanup interval removes expired entries every 10 minutes. Co-Authored-By: Claude Opus 4.6 --- server/src/middleware/jwt.test.ts | 15 ++++++++++++++- server/src/middleware/jwt.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/server/src/middleware/jwt.test.ts b/server/src/middleware/jwt.test.ts index 5112e4e..880b666 100644 --- a/server/src/middleware/jwt.test.ts +++ b/server/src/middleware/jwt.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' -import { createJwt, verifyJwt } from './jwt.js' +import { createJwt, verifyJwt, blacklistJwt } from './jwt.js' describe('JWT', () => { afterEach(() => { @@ -66,4 +66,17 @@ describe('JWT', () => { expect(verifyJwt('a.b')).toBeNull() expect(verifyJwt('a.b.c.d')).toBeNull() }) + + it('rejects blacklisted JWT (logout)', () => { + const token = createJwt('pubkey-logout', 'bot-logout') + + // Token works before blacklisting + expect(verifyJwt(token)).not.toBeNull() + + // Blacklist the token + blacklistJwt(token) + + // Token is now rejected + expect(verifyJwt(token)).toBeNull() + }) }) diff --git a/server/src/middleware/jwt.ts b/server/src/middleware/jwt.ts index 77cf749..19491fe 100644 --- a/server/src/middleware/jwt.ts +++ b/server/src/middleware/jwt.ts @@ -7,6 +7,29 @@ if (!process.env.JWT_SECRET && process.env.NODE_ENV === 'production') { const JWT_SECRET = process.env.JWT_SECRET || randomBytes(32).toString('hex') const JWT_EXPIRY = 24 * 60 * 60 // 24 hours +// JWT blacklist for logout — tokens are blacklisted until they would expire naturally +const blacklist = new Map() // token -> expiry timestamp (seconds) + +/** Blacklist a JWT so it can no longer be verified. */ +export function blacklistJwt(token: string): void { + const parts = token.split('.') + if (parts.length !== 3) return + try { + const decoded = JSON.parse(Buffer.from(parts[1], 'base64url').toString()) as JwtPayload + if (decoded.exp) { + blacklist.set(token, decoded.exp) + } + } catch { /* ignore malformed tokens */ } +} + +// Clean up expired blacklist entries every 10 minutes +export const blacklistCleanupInterval = setInterval(() => { + const now = Math.floor(Date.now() / 1000) + for (const [token, exp] of blacklist) { + if (now >= exp) blacklist.delete(token) + } +}, 10 * 60 * 1000) + if (!process.env.JWT_SECRET) { logger.warn('jwt', 'JWT_SECRET not set — tokens will invalidate on server restart') } @@ -45,6 +68,9 @@ export function verifyJwt(token: string): JwtPayload | null { const parts = token.split('.') if (parts.length !== 3) return null + // Check blacklist before expensive signature verification + if (blacklist.has(token)) return null + const [header, payload, signature] = parts const expectedSig = createHmac('sha256', JWT_SECRET) .update(`${header}.${payload}`)