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:
co-authored by
Claude Opus 4.6
parent
ca9f5f36e6
commit
a96e8922b6
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user