fix: throw if JWT_SECRET missing in production (BUG-S5)
Production now requires JWT_SECRET env var. Added comprehensive JWT tests: creation, verification, expiry, tampered payload, tampered signature, and malformed token rejection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e4a7f47e0f
commit
b82c2755aa
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { createJwt, verifyJwt } from './jwt.js'
|
||||||
|
|
||||||
|
describe('JWT', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a valid JWT that can be verified', () => {
|
||||||
|
const token = createJwt('abc123pubkey', 'bot-1')
|
||||||
|
const payload = verifyJwt(token)
|
||||||
|
expect(payload).not.toBeNull()
|
||||||
|
expect(payload!.sub).toBe('abc123pubkey')
|
||||||
|
expect(payload!.botId).toBe('bot-1')
|
||||||
|
expect(payload!.iat).toBeDefined()
|
||||||
|
expect(payload!.exp).toBeGreaterThan(payload!.iat)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates JWT without botId', () => {
|
||||||
|
const token = createJwt('pubkey-only')
|
||||||
|
const payload = verifyJwt(token)
|
||||||
|
expect(payload).not.toBeNull()
|
||||||
|
expect(payload!.sub).toBe('pubkey-only')
|
||||||
|
expect(payload!.botId).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects expired JWT', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const token = createJwt('pubkey-expire')
|
||||||
|
|
||||||
|
// Advance time past 24h expiry
|
||||||
|
vi.advanceTimersByTime(25 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
const payload = verifyJwt(token)
|
||||||
|
expect(payload).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects tampered payload', () => {
|
||||||
|
const token = createJwt('original-pubkey')
|
||||||
|
const parts = token.split('.')
|
||||||
|
|
||||||
|
// Tamper with payload — change the pubkey
|
||||||
|
const tampered = Buffer.from(JSON.stringify({
|
||||||
|
sub: 'hacker-pubkey',
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86400,
|
||||||
|
})).toString('base64url')
|
||||||
|
|
||||||
|
const tamperedToken = `${parts[0]}.${tampered}.${parts[2]}`
|
||||||
|
const payload = verifyJwt(tamperedToken)
|
||||||
|
expect(payload).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects tampered signature', () => {
|
||||||
|
const token = createJwt('pubkey-sig-test')
|
||||||
|
const parts = token.split('.')
|
||||||
|
const badSig = parts[2].split('').reverse().join('')
|
||||||
|
const tamperedToken = `${parts[0]}.${parts[1]}.${badSig}`
|
||||||
|
const payload = verifyJwt(tamperedToken)
|
||||||
|
expect(payload).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed tokens', () => {
|
||||||
|
expect(verifyJwt('')).toBeNull()
|
||||||
|
expect(verifyJwt('not-a-jwt')).toBeNull()
|
||||||
|
expect(verifyJwt('a.b')).toBeNull()
|
||||||
|
expect(verifyJwt('a.b.c.d')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createHmac, randomBytes } from 'crypto'
|
import { createHmac, randomBytes } from 'crypto'
|
||||||
import { logger } from '../lib/logger.js'
|
import { logger } from '../lib/logger.js'
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET && process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error('JWT_SECRET required in production')
|
||||||
|
}
|
||||||
|
if (!process.env.JWT_SECRET && process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error('JWT_SECRET required in production')
|
||||||
|
}
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || randomBytes(32).toString('hex')
|
const JWT_SECRET = process.env.JWT_SECRET || randomBytes(32).toString('hex')
|
||||||
const JWT_EXPIRY = 24 * 60 * 60 // 24 hours
|
const JWT_EXPIRY = 24 * 60 * 60 // 24 hours
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user