Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<unknown, AuthenticatedLocals>,
Expand All @@ -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<string, unknown>).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');
}
Expand Down
28 changes: 28 additions & 0 deletions tests/helpers/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
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}.`;
}
Loading
Loading