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
13 changes: 11 additions & 2 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { Server } = require('socket.io');
const app = require('./app');
const env = require('./src/config/env');
const { verifyToken } = require('./src/config/jwt');
const userModel = require('./src/models/user.model');
const { registerMessageHandlers } = require('./src/sockets/messages.socket');
const {
registerNotificationHandlers,
Expand All @@ -27,14 +28,22 @@ const io = new Server(server, {
initNotificationSocket(io);

// FIX 3 — single middleware block, duplicate removed
io.use((socket, next) => {
io.use(async (socket, next) => {
const authHeader = socket.handshake.auth?.token;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next(new Error('Access token required'));
}
const token = authHeader.split(' ')[1];
try {
socket.user = verifyToken(token);
const decoded = verifyToken(token);
socket.user = decoded;

// Check if user is suspended in the database
const user = await userModel.findById(decoded.sub);
if (!user || user.is_suspended) {
return next(new Error('Your account is suspended or no longer exists.'));
}

next();
} catch {
return next(new Error('Invalid or expired token'));
Expand Down
10 changes: 9 additions & 1 deletion src/config/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ const signRefreshToken = (payload) =>

const verifyToken = (token) => jwt.verify(token, env.JWT_SECRET);

module.exports = { signAccessToken, signRefreshToken, verifyToken };
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: true,
sameSite: 'none',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
};

module.exports = { signAccessToken, signRefreshToken, verifyToken, REFRESH_COOKIE_OPTIONS };
34 changes: 17 additions & 17 deletions src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,17 @@ const { isValidEmail, isValidPassword } = require('../utils/validators');
// secure must be true only in production — in development (HTTP localhost)
// secure: true causes the browser to silently drop the cookie entirely.

const refreshCookieOptions = {
httpOnly: true,
secure: true,
sameSite: 'none',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
};
const { REFRESH_COOKIE_OPTIONS } = require('../config/jwt');

const setRefreshTokenCookie = (res, token) => {
res.cookie('refreshToken', token, refreshCookieOptions);
res.cookie('refreshToken', token, REFRESH_COOKIE_OPTIONS);
// Backward compatibility while frontend migrates from snake_case key.
res.cookie('refresh_token', token, refreshCookieOptions);
res.cookie('refresh_token', token, REFRESH_COOKIE_OPTIONS);
};

const clearRefreshTokenCookie = (res) => {
res.clearCookie('refreshToken', refreshCookieOptions);
res.clearCookie('refresh_token', refreshCookieOptions);
res.clearCookie('refreshToken', REFRESH_COOKIE_OPTIONS);
res.clearCookie('refresh_token', REFRESH_COOKIE_OPTIONS);
};

exports.register = async (req, res) => {
Expand Down Expand Up @@ -127,17 +121,23 @@ exports.login = async (req, res) => {
};

exports.refresh = async (req, res) => {
// ✅ FIX: Removed console.log('Cookies:', req.cookies) — was leaking
// refresh tokens to server logs in plain text.
const refresh_token =
req.cookies?.refreshToken || req.cookies?.refresh_token || req.body?.refresh_token;

const data = await authService.refresh({ refresh_token });
const { refresh_token: newRefreshToken, ...responseData } = data;
try {
const data = await authService.refresh({ refresh_token });
const { refresh_token: newRefreshToken, ...responseData } = data;

setRefreshTokenCookie(res, newRefreshToken);
setRefreshTokenCookie(res, newRefreshToken);

return success(res, responseData, 'Token refreshed successfully.');
return success(res, responseData, 'Token refreshed successfully.');
} catch (err) {
// If the account is suspended during refresh, clear the cookies to force logout
if (err.errorCode === 'AUTH_ACCOUNT_SUSPENDED') {
clearRefreshTokenCookie(res);
}
throw err;
}
};

exports.logout = async (req, res) => {
Expand Down
6 changes: 5 additions & 1 deletion src/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// middleware/auth.js — JWT verification & token extraction
// Attaches decoded user payload to req.user
// ============================================================
const { verifyToken } = require('../config/jwt');
const { verifyToken, REFRESH_COOKIE_OPTIONS } = require('../config/jwt');
const { error } = require('../utils/api-response');
const userModel = require('../models/user.model');

Expand All @@ -24,6 +24,10 @@ const authenticate = async (req, res, next) => {
}

if (user.is_suspended) {
// Immediate passive logout: clear session cookies
res.clearCookie('refreshToken', REFRESH_COOKIE_OPTIONS);
res.clearCookie('refresh_token', REFRESH_COOKIE_OPTIONS);

return error(
res,
'AUTH_ACCOUNT_SUSPENDED',
Expand Down
90 changes: 37 additions & 53 deletions tests/auth/controllers/auth.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,9 @@ describe('verifyEmail', () => {

await controller.verifyEmail(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'token', issue: 'Verification token is required' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'token', issue: 'Verification token is required' },
]);
expect(authService.verifyEmail).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -133,13 +129,9 @@ describe('login', () => {

await controller.login(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'identifier', issue: 'Email or username is required' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'identifier', issue: 'Email or username is required' },
]);
expect(authService.login).not.toHaveBeenCalled();
});

Expand All @@ -149,13 +141,9 @@ describe('login', () => {

await controller.login(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'password', issue: 'Password is required' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'password', issue: 'Password is required' },
]);
expect(authService.login).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -190,8 +178,12 @@ describe('login', () => {
const req = mkReq({ body: { identifier: ' user@example.com ', password: 'P1' } });
const res = mkRes();
authService.login.mockResolvedValue({
access_token: 'acc', refresh_token: 'ref',
token_type: 'Bearer', expires_in: 900, user: {}, is_new_user: false,
access_token: 'acc',
refresh_token: 'ref',
token_type: 'Bearer',
expires_in: 900,
user: {},
is_new_user: false,
});

await controller.login(req, res);
Expand Down Expand Up @@ -296,13 +288,9 @@ describe('requestPasswordReset', () => {

await controller.requestPasswordReset(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'email', issue: 'Must be a valid email address' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'email', issue: 'Must be a valid email address' },
]);
expect(authService.requestPasswordReset).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -404,13 +392,9 @@ describe('resendVerification', () => {

await controller.resendVerification(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'email', issue: 'Email is required' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'email', issue: 'Email is required' },
]);
});

it('calls service with normalized email', async () => {
Expand All @@ -437,13 +421,9 @@ describe('changeEmail', () => {

await controller.changeEmail(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'new_email', issue: 'Must be a valid email address' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'new_email', issue: 'Must be a valid email address' },
]);
});

it('calls service with userId and normalized email', async () => {
Expand Down Expand Up @@ -471,25 +451,29 @@ describe('deleteAccount', () => {

await controller.deleteAccount(req, res);

expect(api.error).toHaveBeenCalledWith(
res,
'VALIDATION_FAILED',
'Validation failed',
400,
[{ field: 'password', issue: 'Password confirmation is required' }]
);
expect(api.error).toHaveBeenCalledWith(res, 'VALIDATION_FAILED', 'Validation failed', 400, [
{ field: 'password', issue: 'Password confirmation is required' },
]);
expect(authService.deleteAccount).not.toHaveBeenCalled();
});

it('calls service, clears cookies, and returns success', async () => {
const req = mkReq({ body: { password: 'Password1' }, user: { sub: 'u1' }, cookies: { refreshToken: 'tok' } });
const req = mkReq({
body: { password: 'Password1' },
user: { sub: 'u1' },
cookies: { refreshToken: 'tok' },
});
const res = mkRes();
authService.deleteAccount.mockResolvedValue();

await controller.deleteAccount(req, res);

expect(authService.deleteAccount).toHaveBeenCalledWith({ userId: 'u1', password: 'Password1' });
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken', expect.any(Object));
expect(api.success).toHaveBeenCalledWith(res, { success: true }, 'Account deleted successfully.');
expect(api.success).toHaveBeenCalledWith(
res,
{ success: true },
'Account deleted successfully.'
);
});
});
Loading
Loading