Skip to content

Commit 80ec259

Browse files
authored
Merge pull request #43 from shamoo53/Implement-Token-Refresh-Endpoint
Implement-Token-Refresh-Endpoin
2 parents 99f2f55 + 8535c3e commit 80ec259

4 files changed

Lines changed: 330 additions & 3 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
const crypto = require('crypto');
2+
const request = require('supertest');
3+
4+
jest.mock('../models/User.model', () => ({
5+
findById: jest.fn(),
6+
}));
7+
8+
jest.mock('jsonwebtoken', () => ({
9+
verify: jest.fn(),
10+
sign: jest.fn(),
11+
decode: jest.fn(),
12+
}));
13+
14+
const User = require('../models/User.model');
15+
const jwt = require('jsonwebtoken');
16+
const app = require('../app');
17+
18+
describe('POST /api/auth/refresh-token', () => {
19+
beforeAll(() => {
20+
process.env.JWT_SECRET = 'test-access-secret';
21+
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
22+
});
23+
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
it('returns new tokens for valid refresh token', async () => {
29+
const refreshToken = 'valid-refresh-token';
30+
const expectedHash = crypto
31+
.createHash('sha256')
32+
.update(refreshToken)
33+
.digest('hex');
34+
35+
const user = {
36+
_id: '507f1f77bcf86cd799439011',
37+
email: 'john@example.com',
38+
role: 'user',
39+
refreshTokenHash: expectedHash,
40+
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
41+
save: jest.fn().mockResolvedValue(undefined),
42+
};
43+
44+
User.findById.mockReturnValue({
45+
select: jest.fn().mockResolvedValue(user),
46+
});
47+
48+
jwt.verify.mockReturnValue({
49+
sub: '507f1f77bcf86cd799439011',
50+
type: 'refresh',
51+
});
52+
53+
jwt.sign
54+
.mockReturnValueOnce('new-access-token')
55+
.mockReturnValueOnce('new-refresh-token');
56+
jwt.decode.mockReturnValue({ exp: 1735689600 });
57+
58+
const response = await request(app)
59+
.post('/api/auth/refresh-token')
60+
.send({ refreshToken })
61+
.expect(200);
62+
63+
expect(response.body.success).toBe(true);
64+
expect(response.body.message).toBe('Token refreshed successfully');
65+
expect(response.body.data).toEqual({
66+
accessToken: 'new-access-token',
67+
refreshToken: 'new-refresh-token',
68+
});
69+
70+
expect(jwt.verify).toHaveBeenCalledWith(
71+
refreshToken,
72+
'test-refresh-secret'
73+
);
74+
expect(User.findById).toHaveBeenCalledWith('507f1f77bcf86cd799439011');
75+
});
76+
77+
it('returns 403 for invalid token type', async () => {
78+
jwt.verify.mockReturnValue({
79+
sub: '507f1f77bcf86cd799439011',
80+
type: 'access', // Wrong type
81+
});
82+
83+
const response = await request(app)
84+
.post('/api/auth/refresh-token')
85+
.send({ refreshToken: 'access-token' })
86+
.expect(403);
87+
88+
expect(response.body.success).toBe(false);
89+
expect(response.body.message).toBe('Invalid token type');
90+
});
91+
92+
it('returns 403 when user not found', async () => {
93+
User.findById.mockReturnValue({
94+
select: jest.fn().mockResolvedValue(null),
95+
});
96+
97+
jwt.verify.mockReturnValue({
98+
sub: '507f1f77bcf86cd799439011',
99+
type: 'refresh',
100+
});
101+
102+
const response = await request(app)
103+
.post('/api/auth/refresh-token')
104+
.send({ refreshToken: 'valid-refresh-token' })
105+
.expect(403);
106+
107+
expect(response.body.success).toBe(false);
108+
expect(response.body.message).toBe('User not found');
109+
});
110+
111+
it('returns 403 when refresh token hash does not match', async () => {
112+
const user = {
113+
_id: '507f1f77bcf86cd799439011',
114+
email: 'john@example.com',
115+
role: 'user',
116+
refreshTokenHash: 'stored-hash',
117+
refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
118+
save: jest.fn(),
119+
};
120+
121+
User.findById.mockReturnValue({
122+
select: jest.fn().mockResolvedValue(user),
123+
});
124+
125+
jwt.verify.mockReturnValue({
126+
sub: '507f1f77bcf86cd799439011',
127+
type: 'refresh',
128+
});
129+
130+
const response = await request(app)
131+
.post('/api/auth/refresh-token')
132+
.send({ refreshToken: 'different-refresh-token' })
133+
.expect(403);
134+
135+
expect(response.body.success).toBe(false);
136+
expect(response.body.message).toBe('Invalid refresh token');
137+
});
138+
139+
it('returns 403 when refresh token has expired', async () => {
140+
const user = {
141+
_id: '507f1f77bcf86cd799439011',
142+
email: 'john@example.com',
143+
role: 'user',
144+
refreshTokenHash: 'valid-hash',
145+
refreshTokenExpiresAt: new Date(Date.now() - 1000), // Expired
146+
save: jest.fn(),
147+
};
148+
149+
User.findById.mockReturnValue({
150+
select: jest.fn().mockResolvedValue(user),
151+
});
152+
153+
jwt.verify.mockReturnValue({
154+
sub: '507f1f77bcf86cd799439011',
155+
type: 'refresh',
156+
});
157+
158+
const response = await request(app)
159+
.post('/api/auth/refresh-token')
160+
.send({ refreshToken: 'valid-refresh-token' })
161+
.expect(403);
162+
163+
expect(response.body.success).toBe(false);
164+
expect(response.body.message).toBe('Refresh token has expired');
165+
});
166+
167+
it('returns 403 for malformed JWT', async () => {
168+
jwt.verify.mockImplementation(() => {
169+
const error = new Error('JsonWebTokenError');
170+
error.name = 'JsonWebTokenError';
171+
throw error;
172+
});
173+
174+
const response = await request(app)
175+
.post('/api/auth/refresh-token')
176+
.send({ refreshToken: 'malformed-token' })
177+
.expect(403);
178+
179+
expect(response.body.success).toBe(false);
180+
expect(response.body.message).toBe('Invalid or expired refresh token');
181+
});
182+
183+
it('returns 400 when refresh token is missing', async () => {
184+
const response = await request(app)
185+
.post('/api/auth/refresh-token')
186+
.send({})
187+
.expect(400);
188+
189+
expect(response.body.success).toBe(false);
190+
});
191+
});

src/controllers/auth.controller.js

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,10 @@ const forgotPassword = async (req, res, next) => {
289289
}
290290

291291
const resetToken = crypto.randomBytes(32).toString('hex');
292-
const resetTokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
292+
const resetTokenHash = crypto
293+
.createHash('sha256')
294+
.update(resetToken)
295+
.digest('hex');
293296

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

313-
const error = new Error('Failed to send password reset email. Please try again later.');
316+
const error = new Error(
317+
'Failed to send password reset email. Please try again later.'
318+
);
314319
error.statusCode = 500;
315320
error.isOperational = true;
316321
return next(error);
@@ -322,11 +327,125 @@ const forgotPassword = async (req, res, next) => {
322327
}
323328
};
324329

330+
/**
331+
* Refresh access token using a valid refresh token
332+
* POST /api/auth/refresh-token
333+
*/
334+
const refreshToken = async (req, res, next) => {
335+
try {
336+
const { refreshToken } = req.body;
337+
338+
// Verify the refresh token
339+
const decodedRefreshToken = jwt.verify(
340+
refreshToken,
341+
process.env.JWT_REFRESH_SECRET
342+
);
343+
344+
if (decodedRefreshToken.type !== 'refresh') {
345+
const error = new Error('Invalid token type');
346+
error.statusCode = 403;
347+
error.isOperational = true;
348+
return next(error);
349+
}
350+
351+
// Find the user and include the refresh token hash
352+
const user = await User.findById(decodedRefreshToken.sub).select(
353+
'+refreshTokenHash +refreshTokenExpiresAt'
354+
);
355+
356+
if (!user) {
357+
const error = new Error('User not found');
358+
error.statusCode = 403;
359+
error.isOperational = true;
360+
return next(error);
361+
}
362+
363+
// Check if refresh token has expired
364+
if (user.refreshTokenExpiresAt && user.refreshTokenExpiresAt < new Date()) {
365+
const error = new Error('Refresh token has expired');
366+
error.statusCode = 403;
367+
error.isOperational = true;
368+
return next(error);
369+
}
370+
371+
// Hash the provided refresh token and compare with stored hash
372+
const refreshTokenHash = crypto
373+
.createHash('sha256')
374+
.update(refreshToken)
375+
.digest('hex');
376+
377+
if (user.refreshTokenHash !== refreshTokenHash) {
378+
const error = new Error('Invalid refresh token');
379+
error.statusCode = 403;
380+
error.isOperational = true;
381+
return next(error);
382+
}
383+
384+
// Generate new access token
385+
const accessTokenExpiresIn = process.env.JWT_EXPIRES_IN || '15m';
386+
const newAccessToken = jwt.sign(
387+
{
388+
sub: user._id.toString(),
389+
email: user.email,
390+
role: user.role,
391+
type: 'access',
392+
},
393+
process.env.JWT_SECRET,
394+
{ expiresIn: accessTokenExpiresIn }
395+
);
396+
397+
// Optionally rotate the refresh token
398+
const refreshTokenExpiresIn = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
399+
const newRefreshToken = jwt.sign(
400+
{ sub: user._id.toString(), type: 'refresh' },
401+
process.env.JWT_REFRESH_SECRET,
402+
{ expiresIn: refreshTokenExpiresIn }
403+
);
404+
405+
// Hash the new refresh token and update user record
406+
const newRefreshTokenHash = crypto
407+
.createHash('sha256')
408+
.update(newRefreshToken)
409+
.digest('hex');
410+
411+
const newDecodedRefreshToken = jwt.decode(newRefreshToken);
412+
413+
user.refreshTokenHash = newRefreshTokenHash;
414+
user.refreshTokenExpiresAt = newDecodedRefreshToken?.exp
415+
? new Date(newDecodedRefreshToken.exp * 1000)
416+
: null;
417+
418+
await user.save();
419+
420+
return sendSuccess(
421+
res,
422+
{
423+
accessToken: newAccessToken,
424+
refreshToken: newRefreshToken,
425+
},
426+
200,
427+
'Token refreshed successfully'
428+
);
429+
} catch (error) {
430+
if (
431+
error.name === 'JsonWebTokenError' ||
432+
error.name === 'TokenExpiredError'
433+
) {
434+
const tokenError = new Error('Invalid or expired refresh token');
435+
tokenError.statusCode = 403;
436+
tokenError.isOperational = true;
437+
return next(tokenError);
438+
}
439+
return next(error);
440+
}
441+
};
442+
325443
module.exports = {
326444
register,
327445
login,
328446
logout,
329447
resetPassword,
330448
forgotPassword,
331449
verifyEmail,
450+
refreshToken,
332451
};

src/routes/auth.routes.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
resetPassword,
77
forgotPassword,
88
verifyEmail,
9+
refreshToken,
910
} = require('../controllers/auth.controller');
1011
const validate = require('../middlewares/validate');
1112
const authenticate = require('../middlewares/auth');
@@ -14,6 +15,7 @@ const {
1415
loginSchema,
1516
resetPasswordSchema,
1617
forgotPasswordSchema,
18+
refreshTokenSchema,
1719
} = require('../validators/auth.validators');
1820

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

3335
// PATCH /api/auth/reset-password/:token - Reset user password with token
34-
router.patch('/reset-password/:token', validate(resetPasswordSchema), resetPassword);
36+
router.patch(
37+
'/reset-password/:token',
38+
validate(resetPasswordSchema),
39+
resetPassword
40+
);
3541

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

45+
// POST /api/auth/refresh-token - Refresh access token using refresh token
46+
router.post('/refresh-token', validate(refreshTokenSchema), refreshToken);
47+
3948
module.exports = router;

src/validators/auth.validators.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,17 @@ const forgotPasswordSchema = Joi.object({
4545
}),
4646
});
4747

48+
const refreshTokenSchema = Joi.object({
49+
refreshToken: Joi.string().required().messages({
50+
'any.required': 'Refresh token is required',
51+
'string.empty': 'Refresh token cannot be empty',
52+
}),
53+
});
54+
4855
module.exports = {
4956
registerSchema,
5057
loginSchema,
5158
resetPasswordSchema,
5259
forgotPasswordSchema,
60+
refreshTokenSchema,
5361
};

0 commit comments

Comments
 (0)