From cd7978ecfa2b8dbbedf7339f271bd9cfa4145ff4 Mon Sep 17 00:00:00 2001 From: Joseph Okoronkwo Date: Tue, 24 Mar 2026 19:04:18 +0100 Subject: [PATCH] test(auth): jwt validation edge cases --- src/middleware/requireAuth.ts | 55 ++++- tests/helpers/jwt.ts | 28 +++ tests/integration/requireAuth.test.ts | 305 ++++++++++++++++++++++++++ 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 tests/integration/requireAuth.test.ts diff --git a/src/middleware/requireAuth.ts b/src/middleware/requireAuth.ts index 2ff7614..b039039 100644 --- a/src/middleware/requireAuth.ts +++ b/src/middleware/requireAuth.ts @@ -1,7 +1,9 @@ import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; import type { AuthenticatedUser } from '../types/auth.js'; import { UnauthorizedError } from '../errors/index.js'; +import { logger } from '../logger.js'; export interface AuthenticatedLocals { authenticatedUser?: AuthenticatedUser; @@ -14,6 +16,9 @@ declare module 'express-serve-static-core' { } } +/** Restrict accepted signing algorithms to prevent algorithm-confusion attacks. */ +const ALLOWED_ALGORITHMS: jwt.Algorithm[] = ['HS256']; + export const requireAuth = ( req: Request, res: Response, @@ -23,7 +28,55 @@ export const requireAuth = ( const authHeader = req.header('authorization'); if (authHeader?.startsWith('Bearer ')) { - userId = authHeader.slice('Bearer '.length).trim(); + const token = authHeader.slice('Bearer '.length).trim(); + + if (!token) { + next(new UnauthorizedError('Missing token', 'MISSING_TOKEN')); + return; + } + + const secret = process.env.JWT_SECRET; + if (!secret) { + logger.error('[requireAuth] JWT_SECRET is not configured'); + next(new UnauthorizedError()); + return; + } + + try { + const decoded = jwt.verify(token, secret, { + algorithms: ALLOWED_ALGORITHMS, + }); + + // jwt.verify can return a plain string for unsigned payloads + if (typeof decoded === 'string' || !decoded) { + logger.warn('[requireAuth] Token payload is not a valid object'); + next(new UnauthorizedError('Invalid token', 'INVALID_TOKEN')); + return; + } + + const uid = (decoded as Record).userId; + if (typeof uid !== 'string' || uid.trim() === '') { + logger.warn('[requireAuth] Token missing required userId claim'); + next(new UnauthorizedError('Token missing required claims', 'MISSING_CLAIMS')); + return; + } + + userId = uid; + } catch (err) { + // Log the failure reason but never the token contents + const code = err instanceof jwt.TokenExpiredError + ? 'TOKEN_EXPIRED' + : err instanceof jwt.NotBeforeError + ? 'TOKEN_NOT_ACTIVE' + : 'INVALID_TOKEN'; + + logger.warn('[requireAuth] JWT verification failed', { code }); + next(new UnauthorizedError( + code === 'TOKEN_EXPIRED' ? 'Token expired' : 'Invalid token', + code, + )); + return; + } } else { userId = req.header('x-user-id'); } diff --git a/tests/helpers/jwt.ts b/tests/helpers/jwt.ts index 9cce5e9..d4627aa 100644 --- a/tests/helpers/jwt.ts +++ b/tests/helpers/jwt.ts @@ -9,3 +9,31 @@ export function signTestToken(payload: { userId: string; walletAddress: string } export function signExpiredToken(payload: { userId: string; walletAddress: string }) { return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '-1s' }); } + +/** Sign a token using a different secret than the one the server expects. */ +export function signTokenWrongSecret(payload: { userId: string; walletAddress: string }) { + return jwt.sign(payload, 'completely-wrong-secret', { expiresIn: '1h' }); +} + +/** Sign a token with an algorithm the server should reject. */ +export function signTokenWithAlgorithm( + payload: { userId: string; walletAddress: string }, + algorithm: jwt.Algorithm, +) { + return jwt.sign(payload, TEST_JWT_SECRET, { algorithm, expiresIn: '1h' }); +} + +/** Sign a token whose payload is missing the required `userId` claim. */ +export function signTokenMissingClaims(payload: Record) { + return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1h' }); +} + +/** + * Build a token-like string with the `none` algorithm. + * This simulates the classic "alg: none" attack vector. + */ +export function buildNoneAlgorithmToken(payload: { userId: string; walletAddress: string }): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${body}.`; +} diff --git a/tests/integration/requireAuth.test.ts b/tests/integration/requireAuth.test.ts new file mode 100644 index 0000000..db16c8a --- /dev/null +++ b/tests/integration/requireAuth.test.ts @@ -0,0 +1,305 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import request from 'supertest'; +import express from 'express'; +import { requireAuth, type AuthenticatedLocals } from '../../src/middleware/requireAuth.js'; +import { errorHandler } from '../../src/middleware/errorHandler.js'; +import { + TEST_JWT_SECRET, + signTestToken, + signExpiredToken, + signTokenWrongSecret, + signTokenWithAlgorithm, + signTokenMissingClaims, + buildNoneAlgorithmToken, +} from '../helpers/jwt.js'; + +const VALID_PAYLOAD = { + userId: '550e8400-e29b-41d4-a716-446655440000', + walletAddress: 'GDTEST123STELLAR', +}; + +/** + * Minimal Express app that gates a single endpoint behind requireAuth + * and returns the authenticated user id on success. + */ +function buildTestApp() { + const app = express(); + app.use(express.json()); + + app.get( + '/protected', + requireAuth, + (_req: express.Request, res: express.Response) => { + res.json({ userId: res.locals.authenticatedUser?.id }); + }, + ); + + app.use(errorHandler); + return app; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +let app: express.Express; +const originalSecret = process.env.JWT_SECRET; + +beforeEach(() => { + process.env.JWT_SECRET = TEST_JWT_SECRET; + app = buildTestApp(); +}); + +afterEach(() => { + if (originalSecret !== undefined) { + process.env.JWT_SECRET = originalSecret; + } else { + delete process.env.JWT_SECRET; + } +}); + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +describe('requireAuth – happy path', () => { + it('passes through with a valid Bearer JWT and sets authenticatedUser', async () => { + const token = signTestToken(VALID_PAYLOAD); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.userId).toBe(VALID_PAYLOAD.userId); + }); + + it('accepts x-user-id header when no Bearer token is present', async () => { + const res = await request(app) + .get('/protected') + .set('x-user-id', 'user-via-header'); + + expect(res.status).toBe(200); + expect(res.body.userId).toBe('user-via-header'); + }); +}); + +// --------------------------------------------------------------------------- +// Missing credentials +// --------------------------------------------------------------------------- + +describe('requireAuth – missing credentials', () => { + it('returns 401 when no Authorization or x-user-id header is sent', async () => { + const res = await request(app).get('/protected'); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + }); + + it('returns 401 when Authorization header is present but not Bearer scheme', async () => { + const res = await request(app) + .get('/protected') + .set('Authorization', 'Basic dXNlcjpwYXNz'); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + }); + + it('returns 401 when Bearer prefix has no token value', async () => { + // HTTP transport trims trailing whitespace, so "Bearer " becomes "Bearer" + // which does not match the "Bearer " prefix — correctly rejected. + const res = await request(app) + .get('/protected') + .set('Authorization', 'Bearer '); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + }); + + it('returns 401 with MISSING_TOKEN for Bearer followed by only whitespace', async () => { + // Use a tab character that survives transport to trigger the empty-token guard + const res = await request(app) + .get('/protected') + .set('Authorization', 'Bearer \t'); + + expect(res.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// Expired tokens +// --------------------------------------------------------------------------- + +describe('requireAuth – expired tokens', () => { + it('returns 401 with TOKEN_EXPIRED code for an expired JWT', async () => { + const token = signExpiredToken(VALID_PAYLOAD); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Token expired'); + expect(res.body.code).toBe('TOKEN_EXPIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// Malformed tokens +// --------------------------------------------------------------------------- + +describe('requireAuth – malformed tokens', () => { + it('rejects a completely invalid string', async () => { + const res = await request(app) + .get('/protected') + .set('Authorization', 'Bearer not-a-jwt'); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); + + it('rejects a token with only two dot-separated segments and garbage', async () => { + const res = await request(app) + .get('/protected') + .set('Authorization', 'Bearer aaa.bbb.ccc'); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); + + it('rejects a token with valid base64 header but corrupted payload', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${header}.!!!invalid!!!.fakesig`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); + + it('does not leak token content in the error response', async () => { + const sensitiveToken = 'eyJhbGciOiJIUzI1NiJ9.SENSITIVE_DATA.badsig'; + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${sensitiveToken}`); + + expect(res.status).toBe(401); + const body = JSON.stringify(res.body); + expect(body).not.toContain('SENSITIVE_DATA'); + }); +}); + +// --------------------------------------------------------------------------- +// Wrong algorithm +// --------------------------------------------------------------------------- + +describe('requireAuth – algorithm restrictions', () => { + it('rejects a token signed with HS384 when only HS256 is allowed', async () => { + const token = signTokenWithAlgorithm(VALID_PAYLOAD, 'HS384'); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); + + it('rejects a token signed with HS512 when only HS256 is allowed', async () => { + const token = signTokenWithAlgorithm(VALID_PAYLOAD, 'HS512'); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); + + it('rejects a crafted "alg: none" token', async () => { + const token = buildNoneAlgorithmToken(VALID_PAYLOAD); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); +}); + +// --------------------------------------------------------------------------- +// Wrong secret +// --------------------------------------------------------------------------- + +describe('requireAuth – wrong signing secret', () => { + it('rejects a token signed with an incorrect secret', async () => { + const token = signTokenWrongSecret(VALID_PAYLOAD); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('INVALID_TOKEN'); + }); +}); + +// --------------------------------------------------------------------------- +// Missing claims +// --------------------------------------------------------------------------- + +describe('requireAuth – missing or invalid claims', () => { + it('rejects a token that has no userId claim', async () => { + const token = signTokenMissingClaims({ walletAddress: 'GDTEST123STELLAR' }); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('MISSING_CLAIMS'); + expect(res.body.error).toMatch(/missing required claims/i); + }); + + it('rejects a token where userId is an empty string', async () => { + const token = signTokenMissingClaims({ userId: '', walletAddress: 'GDTEST123STELLAR' }); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('MISSING_CLAIMS'); + }); + + it('rejects a token where userId is a number instead of a string', async () => { + const token = signTokenMissingClaims({ userId: 12345, walletAddress: 'GDTEST123STELLAR' }); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('MISSING_CLAIMS'); + }); + + it('rejects a token with only standard JWT claims and no userId', async () => { + const token = signTokenMissingClaims({ sub: 'some-subject', iss: 'callora' }); + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('MISSING_CLAIMS'); + }); +}); + +// --------------------------------------------------------------------------- +// JWT_SECRET not configured +// --------------------------------------------------------------------------- + +describe('requireAuth – server misconfiguration', () => { + it('returns 401 when JWT_SECRET env var is not set', async () => { + delete process.env.JWT_SECRET; + const token = signTestToken(VALID_PAYLOAD); + + const res = await request(app) + .get('/protected') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + }); +});