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
2 changes: 1 addition & 1 deletion src/config/corsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const getCorsConfig = (): CorsOptions => {
}
},

methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
credentials: true,

Expand Down
82 changes: 33 additions & 49 deletions src/middleware/security/requireCsrf.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,56 @@
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { ALLOWED_ORIGINS } from '@constants/globalConts.js';
import { UnauthorizedError } from '@middleware/error/index.js';
import { NextFunction, Request, Response } from 'express';
import logger from '@utils/logger.js';

const unsafeMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];

const normalizeToken = (val: string): string => {
try {
return decodeURIComponent(val).replace(/^"|"$/g, '').trim();
} catch {
return val.replace(/^"|"$/g, '').trim();
}
};

const safeCompare = (a: string, b: string): boolean => {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);

if (aBuf.length !== bBuf.length) return false;

return crypto.timingSafeEqual(aBuf, bBuf);
};

export const csrfMiddleware = (req: Request, _res: Response, next: NextFunction) => {
if (!unsafeMethods.includes(req.method)) {
return next();
}
if (!unsafeMethods.includes(req.method)) return next();

const rawOrigin = req.headers.origin || req.headers.referer;

if (rawOrigin) {
try {
const originUrl = new URL(rawOrigin as string).origin;
if (!rawOrigin) {
return next(new UnauthorizedError('Missing origin'));
}

let origin: string;
try {
origin = new URL(rawOrigin as string).origin;
} catch {
return next(new UnauthorizedError('Invalid origin format'));
}

if (!ALLOWED_ORIGINS.includes(originUrl)) {
return next(new UnauthorizedError('Invalid origin'));
}
} catch {
return next(new UnauthorizedError('Invalid origin format'));
}
if (!ALLOWED_ORIGINS.includes(origin)) {
return next(new UnauthorizedError('Invalid origin'));
}

const csrfHeaderRaw = req.headers['x-csrf-token'];
const csrfHeader = Array.isArray(csrfHeaderRaw) ? csrfHeaderRaw[0] : csrfHeaderRaw;
const headerTokenRaw = req.headers['x-csrf-token'];
const headerToken = Array.isArray(headerTokenRaw) ? headerTokenRaw[0] : headerTokenRaw;

const csrfCookieRaw = req.cookies['csrf-token'];
const cookieToken = req.cookies?.['csrf_token'];

if (!csrfHeader || !csrfCookieRaw) {
return next(new UnauthorizedError('CSRF credentials missing'));
if (!headerToken || !cookieToken) {
return next(new UnauthorizedError('Missing CSRF token'));
}

const csrfToken = normalizeToken(csrfHeader);
const csrfCookie = normalizeToken(csrfCookieRaw);
if (headerToken !== cookieToken) {
return next(new UnauthorizedError('CSRF token mismatch'));
}
Comment on lines +28 to +39

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new CSRF check does a raw headerToken !== cookieToken comparison without normalization/decoding. In practice, cookie values can be quoted or URL-encoded by clients/proxies, which the previous normalizeToken handled; this change can cause valid requests to be rejected. Consider restoring normalization (decode + trim quotes) and using a timing-safe comparison to avoid regressions.

Copilot uses AI. Check for mistakes.

const isValid = safeCompare(csrfToken, csrfCookie);
let decoded: any;
try {
decoded = jwt.verify(headerToken, process.env.CSRF_TOKEN_SECRET!);
} catch {
return next(new UnauthorizedError('Invalid CSRF token'));
}

if (!isValid) {
logger.warn(`'CSRF DEBUG',
header: ${csrfHeader},
cookie: ${csrfCookieRaw},
normalizedHeader: ${csrfToken},
normalizedCookie: ${csrfCookie},
headerLength: ${csrfToken.length},
cookieLength: ${csrfCookie.length},
`);
if (!req.sessionId || decoded.sessionId !== req.sessionId) {
return next(new UnauthorizedError('Invalid CSRF session'));
}

return next(new UnauthorizedError('Invalid CSRF token'));
if (decoded.userId && req.user?.id && decoded.userId !== req.user.id) {
return next(new UnauthorizedError('CSRF user mismatch'));
}

return next();
Expand Down
55 changes: 33 additions & 22 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ import type {
} from '@validation/auth.schema.js';
import { sendSuccess } from '@shared/response.js';

// ─── Cookie Configuration ───────────────────────────────────────────────────

const CSRF_AGE = 15 * 24 * 60 * 60 * 1000; // 15 days
const REFRESH_AGE = 15 * 24 * 60 * 60 * 1000; // 15 days
const ACCESS_AGE = 30 * 60 * 1000; // 30 minutes

Expand All @@ -33,21 +30,23 @@ const cookieOptions: CookieOptions = {
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
domain: process.env.NODE_ENV === 'production' ? process.env.PARENT_DOMAIN : undefined,
maxAge: ACCESS_AGE,
path: '/',
};

/** Sets the three auth cookies (access / refresh / csrf) on the response. */
function setAuthCookies(
res: Response,
tokens: { accessToken: string; refreshToken: string; csrfToken: string }
) {
res
.cookie('access-token', tokens.accessToken, { ...cookieOptions, maxAge: ACCESS_AGE })
.cookie('refresh-token', tokens.refreshToken, { ...cookieOptions, maxAge: REFRESH_AGE })
.cookie('csrf-token', tokens.csrfToken, { ...cookieOptions, httpOnly: false, maxAge: CSRF_AGE });
.cookie('csrf_token', tokens.csrfToken, {
...cookieOptions,
httpOnly: false,
maxAge: REFRESH_AGE,
});
Comment on lines 41 to +47

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cookie name changed from csrf-token to csrf_token. This will break existing logged-in sessions after deploy because clients will still have the old cookie, and csrfMiddleware now only reads csrf_token. Consider a migration period where you accept both cookie names (and/or set/clear both) so existing sessions can continue without forcing users to re-authenticate.

Copilot uses AI. Check for mistakes.
}

// ─── Controllers ────────────────────────────────────────────────────────────

export const registerController = async (req: Request, res: Response) => {
const body = req.validatedBody as RegisterInput;
const userAgent = req.headers['user-agent'] || 'unknown';
Expand All @@ -67,6 +66,7 @@ export const registerController = async (req: Request, res: Response) => {
avatarUrl: user.avatarUrl ?? null,
},
},
csrfToken: tokens.csrfToken,
});
};

Expand All @@ -90,34 +90,45 @@ export const loginController = async (req: Request, res: Response) => {
accountStatus: user.accountStatus,
},
},
csrfToken: tokens.csrfToken,
});
};

export const forgotPasswordController = async (req: Request, res: Response) => {
const { email } = req.validatedBody as ForgetPasswordInput;
await createPasswordResetRequest(email);
return sendSuccess(res, null, 'If an account with that email exists, a reset link has been sent.');
};

export const resetPasswordController = async (req: Request, res: Response) => {
const { token, password } = req.validatedBody as { token: string; password: string };
await resetPassword(token, password);
return sendSuccess(res, null, 'Password updated. Please log in with your new password.');
};

export const refreshTokenController = async (req: Request, res: Response) => {
const reqRefreshToken = req.cookies['refresh-token'];
const userAgent = req.headers['user-agent'] || 'unknown';
const ip = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.ip || req.socket.remoteAddress;
const csrfToken = req.cookies?.['csrf_token'];

if (!reqRefreshToken) {
throw new UnauthenticatedError('No refresh token, authentication failed');
}

if (!csrfToken) {
throw new UnauthenticatedError('Missing CSRF token');
}

const tokens = await refreshTokens(reqRefreshToken, userAgent, ip);
setAuthCookies(res, tokens);
const allTokens = { ...tokens, csrfToken };
setAuthCookies(res, allTokens);
Comment on lines 97 to +113

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshTokenController only checks that the csrf_token cookie exists, but it does not require/validate an x-csrf-token header (double-submit) and does not verify the CSRF JWT against the refresh token’s session/user. A cross-site POST can still trigger a refresh because the browser will attach both cookies automatically. Require a header token (and compare to the cookie) and verify the CSRF token is bound to the refresh token’s decoded sessionId/userId (or otherwise apply CSRF middleware logic tailored for refresh).

Copilot uses AI. Check for mistakes.

return res.status(200).json({
message: 'Tokens refreshed successfully',
data: null,
csrfToken: csrfToken,
});
};

return sendSuccess(res, null, 'Tokens refreshed');
export const forgotPasswordController = async (req: Request, res: Response) => {
const { email } = req.validatedBody as ForgetPasswordInput;
await createPasswordResetRequest(email);
return sendSuccess(res, null, 'If an account with that email exists, a reset link has been sent.');
};

export const resetPasswordController = async (req: Request, res: Response) => {
const { token, password } = req.validatedBody as { token: string; password: string };
await resetPassword(token, password);
return sendSuccess(res, null, 'Password updated. Please log in with your new password.');
};

export const emailVerificationController = async (req: Request, res: Response) => {
Expand Down Expand Up @@ -146,7 +157,7 @@ export const logoutController = async (req: Request, res: Response) => {
return res
.clearCookie('access-token')
.clearCookie('refresh-token')
.clearCookie('csrf-token')
.clearCookie('csrf_token')

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logoutController now clears only csrf_token. If a user still has the legacy csrf-token cookie, it will remain set after logout. Consider clearing both cookie names during the transition to avoid leaving stale CSRF cookies behind.

Suggested change
.clearCookie('csrf_token')
.clearCookie('csrf_token')
.clearCookie('csrf-token')

Copilot uses AI. Check for mistakes.
.status(200)
.json({ message: 'Logged out successfully', data: null });
};
5 changes: 0 additions & 5 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ export async function registerUser(input: RegisterInputWithMeta): Promise<SignOu
throw new ConflictError('Email already registered');
}
}
// Metadata

const passwordHash = await bcrypt.hash(password, 10);

return withTransaction(async (session) => {
Expand Down Expand Up @@ -210,8 +208,6 @@ export async function refreshTokens(

const newRefreshToken = generateRefreshToken(user._id, session.sessionId);

const newCsrfToken = generateCsrfToken();

await SessionModel.updateOne(
{
_id: session._id,
Expand All @@ -228,7 +224,6 @@ export async function refreshTokens(
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
csrfToken: newCsrfToken,
};
}

Expand Down
5 changes: 3 additions & 2 deletions src/modules/auth/utils/createSessionPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { SessionData } from '../../../models/Session.js';
import { generateAllCookieTokens } from '@utils/generateAllCookieTokens.js';
import { getIpInfo } from '@utils/getIpInfo.js';
import { parseUserAgent } from '@utils/parseUserAgent.js';
import { hashToken } from '@utils/tokens.js';
import { generateCsrfToken, hashToken } from '@utils/tokens.js';
import crypto from 'crypto';
import { User } from '@models/User.js';

Expand All @@ -18,12 +18,13 @@ export async function createSessionPayload({
const ipData: any = await getIpInfo(ip);
const userAgentInfo = parseUserAgent(userAgent);
const sessionId = crypto.randomBytes(32).toString('hex');
const { accessToken, refreshToken, csrfToken } = generateAllCookieTokens(
const { accessToken, refreshToken } = generateAllCookieTokens(
user._id,
sessionId,
user.role,
user.accountStatus
);
const csrfToken = generateCsrfToken(user._id, sessionId);

let sessionPayload: Partial<SessionData> = {
sessionId,
Expand Down
8 changes: 6 additions & 2 deletions src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ import {
updateProfile,
} from './user.service.js';
import { sendSuccess } from '@shared/response.js';
import { BadRequestError } from '@middleware/error/index.js';
import { BadRequestError, UnauthorizedError } from '@middleware/error/index.js';

export const getProfileController = async (req: Request, res: Response) => {
const userId = req.user!.id;
const user = await getProfile(userId);
return sendSuccess(res, user, 'Profile retrieved successfully');

return res.status(200).json({
message: 'Profile retrieved successfully',
data: user,
});
Comment on lines +31 to +40

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UnauthorizedError is imported but not used, and getProfileController bypasses sendSuccess, which is used throughout this controller for consistent response shaping/sanitization. Remove the unused import and consider using sendSuccess(res, user, 'Profile retrieved successfully') for consistency.

Copilot uses AI. Check for mistakes.
};

export const updateProfileController = async (req: Request, res: Response) => {
Expand Down
1 change: 0 additions & 1 deletion src/types/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,4 @@ export type SignOutput = {
export interface RefreshTokenOutput {
accessToken: string;
refreshToken: string;
csrfToken: string;
}
22 changes: 13 additions & 9 deletions src/utils/generateAllCookieTokens.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Types } from "mongoose";
import { generateAccessToken, generateCsrfToken, generateRefreshToken } from "./tokens.js";
import { AccountStatus } from "@constants/userConsts.js";
import { Types } from 'mongoose';
import { generateAccessToken, generateRefreshToken } from './tokens.js';
import { AccountStatus } from '@constants/userConsts.js';

export const generateAllCookieTokens = (userId:Types.ObjectId, sessionId:string, role:string, accountStatus:AccountStatus) => {
const accessToken = generateAccessToken(userId, sessionId, role, accountStatus);
const refreshToken = generateRefreshToken(userId, sessionId);
const csrfToken = generateCsrfToken();
return { accessToken, refreshToken, csrfToken };
}
export const generateAllCookieTokens = (
userId: Types.ObjectId,
sessionId: string,
role: string,
accountStatus: AccountStatus
) => {
const accessToken = generateAccessToken(userId, sessionId, role, accountStatus);
const refreshToken = generateRefreshToken(userId, sessionId);
return { accessToken, refreshToken };
};
12 changes: 8 additions & 4 deletions src/utils/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const ACCESS_TOKEN_SECRET: string = process.env.ACCESS_TOKEN_SECRET!;
const REFRESH_TOKEN_EXPIRES_IN: SignOptions['expiresIn'] = process.env
.REFRESH_TOKEN_EXPIRES_IN as SignOptions['expiresIn'];
const REFRESH_TOKEN_SECRET: string = process.env.REFRESH_TOKEN_SECRET!;
const CSRF_TOKEN_EXPIRES_IN: SignOptions['expiresIn'] = process.env
.CSRF_TOKEN_EXPIRES_IN as SignOptions['expiresIn'];
const CSRF_TOKEN_SECRET: string = process.env.CSRF_TOKEN_SECRET!;
Comment on lines +13 to +15

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSRF_TOKEN_EXPIRES_IN / CSRF_TOKEN_SECRET are read with non-null assertions. If these env vars are missing or misconfigured, CSRF token generation/verification will fail at runtime with a hard crash or auth errors. Consider validating required env vars at startup (or providing a safe default) so misconfiguration is detected early and reported clearly.

Copilot uses AI. Check for mistakes.

export function generateVerificationToken(bytes = 32) {
const raw = crypto.randomBytes(bytes).toString('hex');
Expand Down Expand Up @@ -60,13 +63,14 @@ export function generateAccessToken(
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
});
}
export function generateRefreshToken(userId: Types.ObjectId, sessionId: string, reduceTime: boolean = false) {
export function generateRefreshToken(userId: Types.ObjectId, sessionId: string) {
return jwt.sign({ userId, sessionId }, REFRESH_TOKEN_SECRET, {
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
});
}

export function generateCsrfToken() {
const token = crypto.randomBytes(24).toString('hex');
return token;
export function generateCsrfToken(userId: Types.ObjectId, sessionId: string) {
return jwt.sign({ userId, sessionId }, CSRF_TOKEN_SECRET, {
expiresIn: CSRF_TOKEN_EXPIRES_IN,
});
}
Loading