diff --git a/src/__tests__/auth.refresh-token.test.js b/src/__tests__/auth.refresh-token.test.js new file mode 100644 index 0000000..5d21fce --- /dev/null +++ b/src/__tests__/auth.refresh-token.test.js @@ -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); + }); +}); diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index d972670..329f03d 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -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 @@ -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); @@ -322,6 +327,119 @@ 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, @@ -329,4 +447,5 @@ module.exports = { resetPassword, forgotPassword, verifyEmail, + refreshToken, }; diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 34cf8f8..5e5d799 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -6,6 +6,7 @@ const { resetPassword, forgotPassword, verifyEmail, + refreshToken, } = require('../controllers/auth.controller'); const validate = require('../middlewares/validate'); const authenticate = require('../middlewares/auth'); @@ -14,6 +15,7 @@ const { loginSchema, resetPasswordSchema, forgotPasswordSchema, + refreshTokenSchema, } = require('../validators/auth.validators'); const router = express.Router(); @@ -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; diff --git a/src/validators/auth.validators.js b/src/validators/auth.validators.js index 8520c6c..65ca5a6 100644 --- a/src/validators/auth.validators.js +++ b/src/validators/auth.validators.js @@ -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, };