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
191 changes: 191 additions & 0 deletions src/__tests__/auth.refresh-token.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
const crypto = require('crypto');
const request = require('supertest');

jest.mock('../models/User.model', () => ({
findById: jest.fn(),
}));

jest.mock('jsonwebtoken', () => ({
verify: jest.fn(),
sign: jest.fn(),
decode: jest.fn(),
}));

const User = require('../models/User.model');
const jwt = require('jsonwebtoken');
const app = require('../app');

describe('POST /api/auth/refresh-token', () => {
beforeAll(() => {
process.env.JWT_SECRET = 'test-access-secret';
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
});

beforeEach(() => {
jest.clearAllMocks();
});

it('returns new tokens for valid refresh token', async () => {
const refreshToken = 'valid-refresh-token';
const expectedHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');

const user = {
_id: '507f1f77bcf86cd799439011',
email: 'john@example.com',
role: 'user',
refreshTokenHash: expectedHash,
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
save: jest.fn().mockResolvedValue(undefined),
};

User.findById.mockReturnValue({
select: jest.fn().mockResolvedValue(user),
});

jwt.verify.mockReturnValue({
sub: '507f1f77bcf86cd799439011',
type: 'refresh',
});

jwt.sign
.mockReturnValueOnce('new-access-token')
.mockReturnValueOnce('new-refresh-token');
jwt.decode.mockReturnValue({ exp: 1735689600 });

const response = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken })
.expect(200);

expect(response.body.success).toBe(true);
expect(response.body.message).toBe('Token refreshed successfully');
expect(response.body.data).toEqual({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});

expect(jwt.verify).toHaveBeenCalledWith(
refreshToken,
'test-refresh-secret'
);
expect(User.findById).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
});

it('returns 403 for invalid token type', async () => {
jwt.verify.mockReturnValue({
sub: '507f1f77bcf86cd799439011',
type: 'access', // Wrong type
});

const response = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: 'access-token' })
.expect(403);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Invalid token type');
});

it('returns 403 when user not found', async () => {
User.findById.mockReturnValue({
select: jest.fn().mockResolvedValue(null),
});

jwt.verify.mockReturnValue({
sub: '507f1f77bcf86cd799439011',
type: 'refresh',
});

const response = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: 'valid-refresh-token' })
.expect(403);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('User not found');
});

it('returns 403 when refresh token hash does not match', async () => {
const user = {
_id: '507f1f77bcf86cd799439011',
email: 'john@example.com',
role: 'user',
refreshTokenHash: 'stored-hash',
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
save: jest.fn(),
};

User.findById.mockReturnValue({
select: jest.fn().mockResolvedValue(user),
});

jwt.verify.mockReturnValue({
sub: '507f1f77bcf86cd799439011',
type: 'refresh',
});

const response = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: 'different-refresh-token' })
.expect(403);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Invalid refresh token');
});

it('returns 403 when refresh token has expired', async () => {
const user = {
_id: '507f1f77bcf86cd799439011',
email: 'john@example.com',
role: 'user',
refreshTokenHash: 'valid-hash',
refreshTokenExpiresAt: new Date(Date.now() - 1000), // Expired
save: jest.fn(),
};

User.findById.mockReturnValue({
select: jest.fn().mockResolvedValue(user),
});

jwt.verify.mockReturnValue({
sub: '507f1f77bcf86cd799439011',
type: 'refresh',
});

const response = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: 'valid-refresh-token' })
.expect(403);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Refresh token has expired');
});

it('returns 403 for malformed JWT', async () => {
jwt.verify.mockImplementation(() => {
const error = new Error('JsonWebTokenError');
error.name = 'JsonWebTokenError';
throw error;
});

const response = await request(app)
.post('/api/auth/refresh-token')
.send({ refreshToken: 'malformed-token' })
.expect(403);

expect(response.body.success).toBe(false);
expect(response.body.message).toBe('Invalid or expired refresh token');
});

it('returns 400 when refresh token is missing', async () => {
const response = await request(app)
.post('/api/auth/refresh-token')
.send({})
.expect(400);

expect(response.body.success).toBe(false);
});
});
123 changes: 121 additions & 2 deletions src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,10 @@ const forgotPassword = async (req, res, next) => {
}

const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
const resetTokenHash = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');

user.resetPasswordToken = resetTokenHash;
user.resetPasswordExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
Expand All @@ -310,7 +313,9 @@ const forgotPassword = async (req, res, next) => {
user.resetPasswordExpires = null;
await user.save();

const error = new Error('Failed to send password reset email. Please try again later.');
const error = new Error(
'Failed to send password reset email. Please try again later.'
);
error.statusCode = 500;
error.isOperational = true;
return next(error);
Expand All @@ -322,11 +327,125 @@ const forgotPassword = async (req, res, next) => {
}
};

/**
* Refresh access token using a valid refresh token
* POST /api/auth/refresh-token
*/
const refreshToken = async (req, res, next) => {
try {
const { refreshToken } = req.body;

// Verify the refresh token
const decodedRefreshToken = jwt.verify(
refreshToken,
process.env.JWT_REFRESH_SECRET
);

if (decodedRefreshToken.type !== 'refresh') {
const error = new Error('Invalid token type');
error.statusCode = 403;
error.isOperational = true;
return next(error);
}

// Find the user and include the refresh token hash
const user = await User.findById(decodedRefreshToken.sub).select(
'+refreshTokenHash +refreshTokenExpiresAt'
);

if (!user) {
const error = new Error('User not found');
error.statusCode = 403;
error.isOperational = true;
return next(error);
}

// Check if refresh token has expired
if (user.refreshTokenExpiresAt && user.refreshTokenExpiresAt < new Date()) {
const error = new Error('Refresh token has expired');
error.statusCode = 403;
error.isOperational = true;
return next(error);
}

// Hash the provided refresh token and compare with stored hash
const refreshTokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');

if (user.refreshTokenHash !== refreshTokenHash) {
const error = new Error('Invalid refresh token');
error.statusCode = 403;
error.isOperational = true;
return next(error);
}

// Generate new access token
const accessTokenExpiresIn = process.env.JWT_EXPIRES_IN || '15m';
const newAccessToken = jwt.sign(
{
sub: user._id.toString(),
email: user.email,
role: user.role,
type: 'access',
},
process.env.JWT_SECRET,
{ expiresIn: accessTokenExpiresIn }
);

// Optionally rotate the refresh token
const refreshTokenExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
const newRefreshToken = jwt.sign(
{ sub: user._id.toString(), type: 'refresh' },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: refreshTokenExpiresIn }
);

// Hash the new refresh token and update user record
const newRefreshTokenHash = crypto
.createHash('sha256')
.update(newRefreshToken)
.digest('hex');

const newDecodedRefreshToken = jwt.decode(newRefreshToken);

user.refreshTokenHash = newRefreshTokenHash;
user.refreshTokenExpiresAt = newDecodedRefreshToken?.exp
? new Date(newDecodedRefreshToken.exp * 1000)
: null;

await user.save();

return sendSuccess(
res,
{
accessToken: newAccessToken,
refreshToken: newRefreshToken,
},
200,
'Token refreshed successfully'
);
} catch (error) {
if (
error.name === 'JsonWebTokenError' ||
error.name === 'TokenExpiredError'
) {
const tokenError = new Error('Invalid or expired refresh token');
tokenError.statusCode = 403;
tokenError.isOperational = true;
return next(tokenError);
}
return next(error);
}
};

module.exports = {
register,
login,
logout,
resetPassword,
forgotPassword,
verifyEmail,
refreshToken,
};
11 changes: 10 additions & 1 deletion src/routes/auth.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const {
resetPassword,
forgotPassword,
verifyEmail,
refreshToken,
} = require('../controllers/auth.controller');
const validate = require('../middlewares/validate');
const authenticate = require('../middlewares/auth');
Expand All @@ -14,6 +15,7 @@ const {
loginSchema,
resetPasswordSchema,
forgotPasswordSchema,
refreshTokenSchema,
} = require('../validators/auth.validators');

const router = express.Router();
Expand All @@ -31,9 +33,16 @@ router.post('/logout', authenticate, logout);
router.post('/forgot-password', validate(forgotPasswordSchema), forgotPassword);

// PATCH /api/auth/reset-password/:token - Reset user password with token
router.patch('/reset-password/:token', validate(resetPasswordSchema), resetPassword);
router.patch(
'/reset-password/:token',
validate(resetPasswordSchema),
resetPassword
);

// GET /api/auth/verify-email/:token - Verify email address using token from verification email
router.get('/verify-email/:token', verifyEmail);

// POST /api/auth/refresh-token - Refresh access token using refresh token
router.post('/refresh-token', validate(refreshTokenSchema), refreshToken);

module.exports = router;
8 changes: 8 additions & 0 deletions src/validators/auth.validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,17 @@ const forgotPasswordSchema = Joi.object({
}),
});

const refreshTokenSchema = Joi.object({
refreshToken: Joi.string().required().messages({
'any.required': 'Refresh token is required',
'string.empty': 'Refresh token cannot be empty',
}),
});

module.exports = {
registerSchema,
loginSchema,
resetPasswordSchema,
forgotPasswordSchema,
refreshTokenSchema,
};