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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-12 23:45:57 +00:00
co-authored by Claude Opus 4.6
parent ca9f5f36e6
commit a96e8922b6
2 changed files with 40 additions and 1 deletions
+14 -1
View File
@@ -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()
})
})
+26
View File
@@ -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<string, number>() // 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}`)