From b82c2755aad06c77070549400be4fb3c347f2c69 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 22:48:30 +0000 Subject: [PATCH] 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 --- server/src/middleware/jwt.test.ts | 69 +++++++++++++++++++++++++++++++ server/src/middleware/jwt.ts | 6 +++ 2 files changed, 75 insertions(+) create mode 100644 server/src/middleware/jwt.test.ts diff --git a/server/src/middleware/jwt.test.ts b/server/src/middleware/jwt.test.ts new file mode 100644 index 0000000..5112e4e --- /dev/null +++ b/server/src/middleware/jwt.test.ts @@ -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() + }) +}) diff --git a/server/src/middleware/jwt.ts b/server/src/middleware/jwt.ts index 5c657b4..1e87677 100644 --- a/server/src/middleware/jwt.ts +++ b/server/src/middleware/jwt.ts @@ -1,6 +1,12 @@ import { createHmac, randomBytes } from 'crypto' 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_EXPIRY = 24 * 60 * 60 // 24 hours