nodejs-quickstart-structure 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,13 @@
1
1
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2
+
3
+ ## [2.2.1] - 2026-05-12
4
+
5
+ ### Added
6
+ - **Secure OAuth CSRF Protection**: Implemented robust state-validation using cryptographically secure random tokens and `HttpOnly` cookies to mitigate CSRF attacks in social authentication flows.
7
+ - **Improved CLI Robustness**: Enhanced argument parsing to support comma-separated strings for variadic flags like `--social-auth` and `--auth`.
8
+
9
+ ### Fixed
10
+ - **Architectural Parity & Type Safety**: Standardized `HTTP_STATUS.FORBIDDEN` across all templates and resolved TypeScript type inference issues (`never` type) in generated Clean Architecture controllers.
2
11
 
3
12
  ## [2.2.0] - 2026-05-05
4
13
 
package/lib/prompts.js CHANGED
@@ -139,7 +139,9 @@ export const getProjectDetails = async (options = {}) => {
139
139
 
140
140
  // Normalize auth to array if it's a string from the options
141
141
  if (typeof result.auth === 'string') {
142
- result.auth = [result.auth];
142
+ result.auth = result.auth.split(',').map(s => s.trim());
143
+ } else if (Array.isArray(result.auth)) {
144
+ result.auth = result.auth.flatMap(s => s.split(',').map(ss => ss.trim()));
143
145
  }
144
146
 
145
147
  // Map friendly CLI strings to actual values
@@ -162,7 +164,9 @@ export const getProjectDetails = async (options = {}) => {
162
164
 
163
165
  // Normalize socialAuth to array from options
164
166
  if (typeof result.socialAuth === 'string') {
165
- result.socialAuth = [result.socialAuth];
167
+ result.socialAuth = result.socialAuth.split(',').map(s => s.trim());
168
+ } else if (Array.isArray(result.socialAuth)) {
169
+ result.socialAuth = result.socialAuth.flatMap(s => s.split(',').map(ss => ss.trim()));
166
170
  }
167
171
 
168
172
  // Default socialAuth if not provided
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodejs-quickstart-structure",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "description": "The ultimate nodejs quickstart structure CLI to scaffold Node.js microservices with MVC or Clean Architecture",
6
6
  "main": "bin/index.js",
@@ -1,5 +1,6 @@
1
1
  const express = require('express');
2
2
  const cors = require('cors');
3
+ const cookieParser = require('cookie-parser');
3
4
  const logger = require('../log/logger');
4
5
  const morgan = require('morgan');
5
6
  const { errorMiddleware } = require('./middleware/errorMiddleware');
@@ -30,6 +31,7 @@ const startServer = async () => {
30
31
  const app = express();
31
32
 
32
33
  app.use(cors());
34
+ app.use(cookieParser());
33
35
  app.use(express.json());
34
36
  app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
35
37
  <%_ if (communication === 'REST APIs' || communication === 'Kafka') { -%>
@@ -4,6 +4,7 @@ import cors from 'cors';
4
4
  import helmet from 'helmet';
5
5
  import hpp from 'hpp';
6
6
  import rateLimit from 'express-rate-limit';
7
+ import cookieParser from 'cookie-parser';
7
8
  import logger from '@/infrastructure/log/logger';
8
9
  import morgan from 'morgan';
9
10
  import { errorMiddleware } from '@/utils/errorMiddleware';
@@ -50,6 +51,7 @@ app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE'] }));
50
51
  const limiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 100 });
51
52
  app.use(limiter);
52
53
 
54
+ app.use(cookieParser());
53
55
  app.use(express.json());
54
56
  app.use(morgan('combined', { stream: { write: (message) => logger.info(message.trim()) } }));
55
57
 
@@ -3,6 +3,7 @@ export const HTTP_STATUS = {
3
3
  CREATED: 201,
4
4
  BAD_REQUEST: 400,
5
5
  UNAUTHORIZED: 401,
6
+ FORBIDDEN: 403,
6
7
  NOT_FOUND: 404,
7
8
  INTERNAL_SERVER_ERROR: 500
8
9
  } as const;
@@ -1,4 +1,5 @@
1
1
  const bcrypt = require('bcryptjs');
2
+ const crypto = require('crypto');
2
3
  <%_ if (architecture === 'MVC') { _%>
3
4
  const User = require('../models/User');
4
5
  const JwtService = require('../services/jwtService');
@@ -40,6 +41,16 @@ class AuthController {
40
41
  this.githubCallback = this.githubCallback.bind(this);
41
42
  <% } -%>
42
43
  <% } -%>
44
+ this.setOAuthStateCookie = this.setOAuthStateCookie.bind(this);
45
+ }
46
+
47
+ setOAuthStateCookie(res, state) {
48
+ res.cookie('oauth_state', state, {
49
+ httpOnly: true,
50
+ secure: process.env.NODE_ENV === 'production',
51
+ sameSite: 'lax',
52
+ maxAge: 10 * 60 * 1000
53
+ });
43
54
  }
44
55
 
45
56
  async login(req, res, next) {
@@ -296,7 +307,7 @@ class AuthController {
296
307
  <%_ } _%>
297
308
 
298
309
  res.json({ token: accessToken, accessToken, refreshToken });
299
- <%_ } _%>
310
+ <% } %>
300
311
  } catch (error) {
301
312
  logger.error('Social exchange error:', error);
302
313
  next(error);
@@ -307,6 +318,9 @@ class AuthController {
307
318
  <% if (socialAuth.includes('Google')) { -%>
308
319
  async googleLogin(req, res) {
309
320
  const rootUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
321
+ const state = crypto.randomBytes(16).toString('hex');
322
+ this.setOAuthStateCookie(res, state);
323
+
310
324
  const options = {
311
325
  redirect_uri: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/api/auth/google/callback',
312
326
  client_id: process.env.GOOGLE_CLIENT_ID,
@@ -317,15 +331,22 @@ class AuthController {
317
331
  'https://www.googleapis.com/auth/userinfo.profile',
318
332
  'https://www.googleapis.com/auth/userinfo.email',
319
333
  ].join(' '),
320
- state: 'google'
334
+ state: state
321
335
  };
322
336
  const qs = new URLSearchParams(options);
323
337
  res.redirect(`${rootUrl}?${qs.toString()}`);
324
338
  }
325
339
 
326
- async googleCallback(req, res, next) {
340
+ async googleCallback(req, res, _next) {
327
341
  try {
328
- const { code } = req.query;
342
+ const { code, state } = req.query;
343
+ const savedState = req.cookies?.oauth_state;
344
+ res.clearCookie('oauth_state');
345
+
346
+ if (!state || state !== savedState) {
347
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ message: 'Invalid state parameter' });
348
+ }
349
+
329
350
  const redirectUri = process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/api/auth/google/callback';
330
351
 
331
352
  <%_ if (architecture === 'Clean Architecture') { _%>
@@ -344,7 +365,7 @@ class AuthController {
344
365
  const activeTokens = JwtService.activeRefreshTokens.get(userId) || [];
345
366
  activeTokens.push(refreshJti);
346
367
  JwtService.activeRefreshTokens.set(userId, activeTokens);
347
- <%_ } _%>
368
+ <% } %>
348
369
 
349
370
  res.cookie('accessToken', accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
350
371
  res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
@@ -382,12 +403,12 @@ class AuthController {
382
403
  const activeTokens = JwtService.activeRefreshTokens.get(userId) || [];
383
404
  activeTokens.push(refreshJti);
384
405
  JwtService.activeRefreshTokens.set(userId, activeTokens);
385
- <%_ } _%>
406
+ <% } %>
386
407
 
387
408
  res.cookie('accessToken', accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
388
409
  res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
389
410
  res.redirect('/');
390
- <%_ } _%>
411
+ <% } %>
391
412
  } catch (error) {
392
413
  logger.error('Google callback error:', error);
393
414
  res.redirect('/login?error=social_auth_failed');
@@ -398,19 +419,28 @@ class AuthController {
398
419
  <% if (socialAuth.includes('GitHub')) { -%>
399
420
  async githubLogin(req, res) {
400
421
  const rootUrl = 'https://github.com/login/oauth/authorize';
422
+ const state = crypto.randomBytes(16).toString('hex');
423
+ this.setOAuthStateCookie(res, state);
424
+
401
425
  const options = {
402
426
  client_id: process.env.GITHUB_CLIENT_ID,
403
427
  redirect_uri: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/api/auth/github/callback',
404
428
  scope: 'user:email',
405
- state: 'github'
429
+ state: state
406
430
  };
407
431
  const qs = new URLSearchParams(options);
408
432
  res.redirect(`${rootUrl}?${qs.toString()}`);
409
433
  }
410
434
 
411
- async githubCallback(req, res, next) {
435
+ async githubCallback(req, res, _next) {
412
436
  try {
413
- const { code } = req.query;
437
+ const { code, state } = req.query;
438
+ const savedState = req.cookies?.oauth_state;
439
+ res.clearCookie('oauth_state');
440
+
441
+ if (!state || state !== savedState) {
442
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ message: 'Invalid state parameter' });
443
+ }
414
444
 
415
445
  <%_ if (architecture === 'Clean Architecture') { _%>
416
446
  const useCase = new SocialLoginUseCase(new GitHubProvider(), new UserRepository());
@@ -428,7 +458,7 @@ class AuthController {
428
458
  const activeTokens = JwtService.activeRefreshTokens.get(userId) || [];
429
459
  activeTokens.push(refreshJti);
430
460
  JwtService.activeRefreshTokens.set(userId, activeTokens);
431
- <%_ } _%>
461
+ <% } %>
432
462
 
433
463
  res.cookie('accessToken', accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
434
464
  res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
@@ -466,7 +496,7 @@ class AuthController {
466
496
  const activeTokens = JwtService.activeRefreshTokens.get(userId) || [];
467
497
  activeTokens.push(refreshJti);
468
498
  JwtService.activeRefreshTokens.set(userId, activeTokens);
469
- <%_ } _%>
499
+ <% } %>
470
500
 
471
501
  res.cookie('accessToken', accessToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
472
502
  res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' });
@@ -6,7 +6,8 @@ jest.mock('@/usecases/auth/socialLoginUseCase');
6
6
  jest.mock('@/infrastructure/repositories/UserRepository');
7
7
  <%_ } _%>
8
8
  jest.mock('@/infrastructure/database/models/User', () => ({
9
- findOne: jest.fn()
9
+ findOne: jest.fn(),
10
+ create: jest.fn()
10
11
  }));
11
12
  <%_ } else { _%>
12
13
  jest.mock('@/services/jwtService');
@@ -14,7 +15,8 @@ jest.mock('@/services/jwtService');
14
15
  jest.mock('@/services/socialAuthService');
15
16
  <%_ } _%>
16
17
  jest.mock('@/models/User', () => ({
17
- findOne: jest.fn()
18
+ findOne: jest.fn(),
19
+ create: jest.fn()
18
20
  }));
19
21
  <%_ } _%>
20
22
  jest.mock('bcryptjs');
@@ -58,12 +60,14 @@ describe('AuthController', () => {
58
60
  mockReq = {
59
61
  body: {},
60
62
  headers: {},
61
- query: {}
63
+ query: {},
64
+ cookies: {}
62
65
  };
63
66
  mockRes = {
64
67
  status: jest.fn().mockReturnThis(),
65
68
  json: jest.fn(),
66
69
  cookie: jest.fn(),
70
+ clearCookie: jest.fn(),
67
71
  redirect: jest.fn()
68
72
  };
69
73
  next = jest.fn();
@@ -269,7 +273,8 @@ describe('AuthController', () => {
269
273
  });
270
274
 
271
275
  it('googleCallback should handle Google callback', async () => {
272
- mockReq.query = { code: 'test-code' };
276
+ mockReq.query = { code: 'test-code', state: 'test-state' };
277
+ mockReq.cookies = { oauth_state: 'test-state' };
273
278
  const user = { id: 1, email: 'google@test.com' };
274
279
 
275
280
  <% if (architecture === 'Clean Architecture') { %>
@@ -294,6 +299,48 @@ describe('AuthController', () => {
294
299
  expect(mockRes.cookie).toHaveBeenCalledWith('accessToken', 'mock-token', expect.any(Object));
295
300
  expect(mockRes.redirect).toHaveBeenCalledWith('/');
296
301
  });
302
+
303
+ it('googleCallback should return 403 if state is invalid', async () => {
304
+ mockReq.query = { code: 'test-code', state: 'invalid-state' };
305
+ mockReq.cookies = { oauth_state: 'valid-state' };
306
+ await controller.googleCallback(mockReq, mockRes, next);
307
+ expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN);
308
+ });
309
+
310
+ it('googleCallback should create user if not exists (MVC)', async () => {
311
+ <% if (architecture === 'MVC') { %>
312
+ mockReq.query = { code: 'test-code', state: 'test-state' };
313
+ mockReq.cookies = { oauth_state: 'test-state' };
314
+ SocialAuthService.getGoogleProfile.mockResolvedValue({ email: 'new@google.com', id: 'google-id' });
315
+ User.findOne.mockResolvedValue(null);
316
+ User.create.mockResolvedValue({ id: '2', email: 'new@google.com' });
317
+ JwtService.generateToken.mockReturnValue('at');
318
+ JwtService.generateRefreshToken.mockReturnValue('rt');
319
+ JwtService.decodeToken.mockReturnValue({ jti: 'jti' });
320
+
321
+ await controller.googleCallback(mockReq, mockRes, next);
322
+ expect(User.create).toHaveBeenCalled();
323
+ <% } else { %>
324
+ expect(true).toBe(true);
325
+ <% } %>
326
+ });
327
+
328
+ it('googleCallback should redirect to login on error', async () => {
329
+ const error = new Error('Callback failed');
330
+ mockReq.query = { code: 'code', state: 'test-state' };
331
+ mockReq.cookies = { oauth_state: 'test-state' };
332
+ <% if (architecture === 'Clean Architecture') { %>
333
+ const mockUseCase = {
334
+ execute: jest.fn().mockRejectedValue(error)
335
+ };
336
+ SocialLoginUseCase.mockImplementation(() => mockUseCase);
337
+ <% } else { %>
338
+ SocialAuthService.getGoogleProfile.mockRejectedValue(error);
339
+ <% } %>
340
+
341
+ await controller.googleCallback(mockReq, mockRes, next);
342
+ expect(mockRes.redirect).toHaveBeenCalledWith('/login?error=social_auth_failed');
343
+ });
297
344
  <% } %>
298
345
 
299
346
  <% if (socialAuth.includes('GitHub')) { %>
@@ -303,7 +350,8 @@ describe('AuthController', () => {
303
350
  });
304
351
 
305
352
  it('githubCallback should handle GitHub callback', async () => {
306
- mockReq.query = { code: 'test-code' };
353
+ mockReq.query = { code: 'test-code', state: 'test-state' };
354
+ mockReq.cookies = { oauth_state: 'test-state' };
307
355
  const user = { id: 1, email: 'github@test.com' };
308
356
 
309
357
  <% if (architecture === 'Clean Architecture') { %>
@@ -328,6 +376,48 @@ describe('AuthController', () => {
328
376
  expect(mockRes.cookie).toHaveBeenCalledWith('accessToken', 'mock-token', expect.any(Object));
329
377
  expect(mockRes.redirect).toHaveBeenCalledWith('/');
330
378
  });
379
+
380
+ it('githubCallback should return 403 if state is invalid', async () => {
381
+ mockReq.query = { code: 'test-code', state: 'invalid-state' };
382
+ mockReq.cookies = { oauth_state: 'valid-state' };
383
+ await controller.githubCallback(mockReq, mockRes, next);
384
+ expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN);
385
+ });
386
+
387
+ it('githubCallback should create user if not exists (MVC)', async () => {
388
+ <% if (architecture === 'MVC') { %>
389
+ mockReq.query = { code: 'test-code', state: 'test-state' };
390
+ mockReq.cookies = { oauth_state: 'test-state' };
391
+ SocialAuthService.getGithubProfile.mockResolvedValue({ email: 'new@github.com', id: 'github-id' });
392
+ User.findOne.mockResolvedValue(null);
393
+ User.create.mockResolvedValue({ id: '2', email: 'new@github.com' });
394
+ JwtService.generateToken.mockReturnValue('at');
395
+ JwtService.generateRefreshToken.mockReturnValue('rt');
396
+ JwtService.decodeToken.mockReturnValue({ jti: 'jti' });
397
+
398
+ await controller.githubCallback(mockReq, mockRes, next);
399
+ expect(User.create).toHaveBeenCalled();
400
+ <% } else { %>
401
+ expect(true).toBe(true);
402
+ <% } %>
403
+ });
404
+
405
+ it('githubCallback should redirect to login on error', async () => {
406
+ const error = new Error('Callback failed');
407
+ mockReq.query = { code: 'code', state: 'test-state' };
408
+ mockReq.cookies = { oauth_state: 'test-state' };
409
+ <% if (architecture === 'Clean Architecture') { %>
410
+ const mockUseCase = {
411
+ execute: jest.fn().mockRejectedValue(error)
412
+ };
413
+ SocialLoginUseCase.mockImplementation(() => mockUseCase);
414
+ <% } else { %>
415
+ SocialAuthService.getGithubProfile.mockRejectedValue(error);
416
+ <% } %>
417
+
418
+ await controller.githubCallback(mockReq, mockRes, next);
419
+ expect(mockRes.redirect).toHaveBeenCalledWith('/login?error=social_auth_failed');
420
+ });
331
421
  <% } %>
332
422
  });
333
423
  <% } -%>
@@ -25,7 +25,7 @@ class JwtService {
25
25
  static verifyToken(token) {
26
26
  try {
27
27
  return jwt.verify(token, this.SECRET);
28
- } catch (error) {
28
+ } catch {
29
29
  return null;
30
30
  }
31
31
  }
@@ -33,7 +33,7 @@ class JwtService {
33
33
  static verifyRefreshToken(token) {
34
34
  try {
35
35
  return jwt.verify(token, this.REFRESH_SECRET);
36
- } catch (error) {
36
+ } catch {
37
37
  return null;
38
38
  }
39
39
  }
@@ -41,7 +41,7 @@ class JwtService {
41
41
  static decodeToken(token) {
42
42
  try {
43
43
  return jwt.decode(token);
44
- } catch (error) {
44
+ } catch {
45
45
  return null;
46
46
  }
47
47
  }
@@ -2,8 +2,6 @@ const axios = require('axios');
2
2
  <% if (architecture === 'MVC') { -%>
3
3
  const { SocialAuthService } = require('@/services/socialAuthService');
4
4
  const logger = require('@/utils/logger');
5
- <% } else { -%>
6
- const logger = require('@/infrastructure/log/logger');
7
5
  <% } -%>
8
6
 
9
7
  jest.mock('axios');
@@ -56,12 +56,14 @@ describe('AuthController', () => {
56
56
  mockRequest = {
57
57
  body: {},
58
58
  headers: {},
59
- query: {}
59
+ query: {},
60
+ cookies: {}
60
61
  };
61
62
  mockResponse = {
62
63
  status: jest.fn().mockReturnThis(),
63
64
  json: jest.fn(),
64
65
  cookie: jest.fn(),
66
+ clearCookie: jest.fn(),
65
67
  redirect: jest.fn()
66
68
  };
67
69
  jest.clearAllMocks();
@@ -335,7 +337,8 @@ describe('AuthController', () => {
335
337
 
336
338
  <% if (socialAuth.includes('Google')) { %>
337
339
  it('googleCallback should handle Google callback', async () => {
338
- mockRequest.query = { code: 'test-code' };
340
+ mockRequest.query = { code: 'test-code', state: 'test-state' };
341
+ mockRequest.cookies = { oauth_state: 'test-state' };
339
342
  const user = { id: 1, email: 'google@test.com' };
340
343
  <%_ if (architecture === 'Clean Architecture') { _%>
341
344
  const mockUseCaseInstance = { execute: jest.fn().mockResolvedValue({ user, accessToken: 'at', refreshToken: 'rt' }) };
@@ -354,9 +357,17 @@ describe('AuthController', () => {
354
357
  expect(mockResponse.redirect).toHaveBeenCalledWith('/');
355
358
  });
356
359
 
360
+ it('googleCallback should return 403 if state is invalid', async () => {
361
+ mockRequest.query = { code: 'test-code', state: 'invalid-state' };
362
+ mockRequest.cookies = { oauth_state: 'valid-state' };
363
+ await authController.googleCallback(mockRequest as Request, mockResponse as Response, nextFunction);
364
+ expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN);
365
+ });
366
+
357
367
  it('googleCallback should create user if not exists (MVC)', async () => {
358
368
  <% if (architecture === 'MVC') { %>
359
- mockRequest.query = { code: 'test-code' };
369
+ mockRequest.query = { code: 'test-code', state: 'test-state' };
370
+ mockRequest.cookies = { oauth_state: 'test-state' };
360
371
  const { SocialAuthService } = require('@/services/socialAuthService');
361
372
  (SocialAuthService.getGoogleProfile as jest.Mock).mockResolvedValue({ email: 'new@google.com', id: 'google-id' });
362
373
  (User.findOne as jest.Mock).mockResolvedValue(null);
@@ -372,7 +383,8 @@ describe('AuthController', () => {
372
383
 
373
384
  it('googleCallback should redirect to login on error', async () => {
374
385
  const error = new Error('Callback failed');
375
- mockRequest.query = { code: 'code' };
386
+ mockRequest.query = { code: 'code', state: 'test-state' };
387
+ mockRequest.cookies = { oauth_state: 'test-state' };
376
388
  <% if (architecture === 'MVC') { %>
377
389
  const { SocialAuthService } = require('@/services/socialAuthService');
378
390
  (SocialAuthService.getGoogleProfile as jest.Mock).mockRejectedValue(error);
@@ -387,7 +399,8 @@ describe('AuthController', () => {
387
399
 
388
400
  <% if (socialAuth.includes('GitHub')) { %>
389
401
  it('githubCallback should handle GitHub callback', async () => {
390
- mockRequest.query = { code: 'test-code' };
402
+ mockRequest.query = { code: 'test-code', state: 'test-state' };
403
+ mockRequest.cookies = { oauth_state: 'test-state' };
391
404
  const user = { id: 1, email: 'github@test.com' };
392
405
  <%_ if (architecture === 'Clean Architecture') { _%>
393
406
  const mockUseCaseInstance = { execute: jest.fn().mockResolvedValue({ user, accessToken: 'at', refreshToken: 'rt' }) };
@@ -406,9 +419,17 @@ describe('AuthController', () => {
406
419
  expect(mockResponse.redirect).toHaveBeenCalledWith('/');
407
420
  });
408
421
 
422
+ it('githubCallback should return 403 if state is invalid', async () => {
423
+ mockRequest.query = { code: 'test-code', state: 'invalid-state' };
424
+ mockRequest.cookies = { oauth_state: 'valid-state' };
425
+ await authController.githubCallback(mockRequest as Request, mockResponse as Response, nextFunction);
426
+ expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN);
427
+ });
428
+
409
429
  it('githubCallback should create user if not exists (MVC)', async () => {
410
430
  <% if (architecture === 'MVC') { %>
411
- mockRequest.query = { code: 'test-code' };
431
+ mockRequest.query = { code: 'test-code', state: 'test-state' };
432
+ mockRequest.cookies = { oauth_state: 'test-state' };
412
433
  const { SocialAuthService } = require('@/services/socialAuthService');
413
434
  (SocialAuthService.getGithubProfile as jest.Mock).mockResolvedValue({ email: 'new@github.com', id: 'github-id' });
414
435
  (User.findOne as jest.Mock).mockResolvedValue(null);
@@ -424,7 +445,8 @@ describe('AuthController', () => {
424
445
 
425
446
  it('githubCallback should redirect to login on error', async () => {
426
447
  const error = new Error('Callback failed');
427
- mockRequest.query = { code: 'code' };
448
+ mockRequest.query = { code: 'code', state: 'test-state' };
449
+ mockRequest.cookies = { oauth_state: 'test-state' };
428
450
  <% if (architecture === 'MVC') { %>
429
451
  const { SocialAuthService } = require('@/services/socialAuthService');
430
452
  (SocialAuthService.getGithubProfile as jest.Mock).mockRejectedValue(error);
@@ -1,5 +1,6 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
2
  import bcrypt from 'bcryptjs';
3
+ import crypto from 'crypto';
3
4
  <% if (architecture === 'MVC') { -%>
4
5
  import User from '@/models/User';
5
6
  import { JwtService } from '@/services/jwtService';
@@ -26,6 +27,15 @@ import { UserRepository } from '@/infrastructure/repositories/UserRepository';
26
27
  import { HTTP_STATUS } from '@/utils/httpCodes';
27
28
 
28
29
  export class AuthController {
30
+ private setOAuthStateCookie(res: Response, state: string) {
31
+ res.cookie('oauth_state', state, {
32
+ httpOnly: true,
33
+ secure: process.env.NODE_ENV === 'production',
34
+ sameSite: 'lax',
35
+ maxAge: 10 * 60 * 1000
36
+ });
37
+ }
38
+
29
39
  async login(req: Request, res: Response, next: NextFunction) {
30
40
  try {
31
41
  const { email, password } = req.body;
@@ -188,7 +198,7 @@ export class AuthController {
188
198
  }
189
199
 
190
200
  <% if (architecture === 'Clean Architecture') { -%>
191
- let useCase;
201
+ let useCase: SocialLoginUseCase | undefined;
192
202
  const userRepository = new UserRepository();
193
203
  <%_ if (socialAuth.includes('Google')) { _%>
194
204
  if (provider === 'Google') useCase = new SocialLoginUseCase(new GoogleProvider(), userRepository);
@@ -304,6 +314,9 @@ export class AuthController {
304
314
  <% if (socialAuth.includes('Google')) { -%>
305
315
  async googleLogin(req: Request, res: Response) {
306
316
  const rootUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
317
+ const state = crypto.randomBytes(16).toString('hex');
318
+ this.setOAuthStateCookie(res, state);
319
+
307
320
  const options = {
308
321
  redirect_uri: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/api/auth/google/callback',
309
322
  client_id: process.env.GOOGLE_CLIENT_ID!,
@@ -314,7 +327,7 @@ export class AuthController {
314
327
  'https://www.googleapis.com/auth/userinfo.profile',
315
328
  'https://www.googleapis.com/auth/userinfo.email',
316
329
  ].join(' '),
317
- state: 'google'
330
+ state: state
318
331
  };
319
332
  const qs = new URLSearchParams(options);
320
333
  res.redirect(`${rootUrl}?${qs.toString()}`);
@@ -322,7 +335,14 @@ export class AuthController {
322
335
 
323
336
  async googleCallback(req: Request, res: Response, next: NextFunction) {
324
337
  try {
325
- const { code } = req.query;
338
+ const { code, state } = req.query;
339
+ const savedState = req.cookies?.oauth_state;
340
+ res.clearCookie('oauth_state');
341
+
342
+ if (!state || state !== savedState) {
343
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ message: 'Invalid state parameter' });
344
+ }
345
+
326
346
  const redirectUri = process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/api/auth/google/callback';
327
347
 
328
348
  <%_ if (architecture === 'Clean Architecture') { _%>
@@ -405,11 +425,14 @@ export class AuthController {
405
425
  <% if (socialAuth.includes('GitHub')) { -%>
406
426
  async githubLogin(req: Request, res: Response) {
407
427
  const rootUrl = 'https://github.com/login/oauth/authorize';
428
+ const state = crypto.randomBytes(16).toString('hex');
429
+ this.setOAuthStateCookie(res, state);
430
+
408
431
  const options = {
409
432
  client_id: process.env.GITHUB_CLIENT_ID!,
410
433
  redirect_uri: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/api/auth/github/callback',
411
434
  scope: 'user:email',
412
- state: 'github'
435
+ state: state
413
436
  };
414
437
  const qs = new URLSearchParams(options);
415
438
  res.redirect(`${rootUrl}?${qs.toString()}`);
@@ -417,7 +440,13 @@ export class AuthController {
417
440
 
418
441
  async githubCallback(req: Request, res: Response, next: NextFunction) {
419
442
  try {
420
- const { code } = req.query;
443
+ const { code, state } = req.query;
444
+ const savedState = req.cookies?.oauth_state;
445
+ res.clearCookie('oauth_state');
446
+
447
+ if (!state || state !== savedState) {
448
+ return res.status(HTTP_STATUS.FORBIDDEN).json({ message: 'Invalid state parameter' });
449
+ }
421
450
 
422
451
  <%_ if (architecture === 'Clean Architecture') { _%>
423
452
  const useCase = new SocialLoginUseCase(new GitHubProvider(), new UserRepository());
@@ -73,7 +73,10 @@ export default [
73
73
  },
74
74
  rules: {
75
75
  "no-console": "warn",
76
- "no-unused-vars": "warn",
76
+ "no-unused-vars": ["warn", {
77
+ "argsIgnorePattern": "^_",
78
+ "varsIgnorePattern": "^_"
79
+ }],
77
80
  "import/no-unresolved": [2, { "caseSensitive": false }]
78
81
  }
79
82
  }
@@ -34,7 +34,7 @@ const connectKafka = async (retries = 10) => {
34
34
  await consumer.run({
35
35
  eachMessage: async (payload) => welcomeConsumer.onMessage(payload),
36
36
  });
37
- } catch (error) {
37
+ } catch {
38
38
  // Fallback or no consumers found
39
39
  await consumer.subscribe({ topic: 'user-topic', fromBeginning: true });
40
40
  await consumer.run({
@@ -62,6 +62,7 @@
62
62
  <% } -%>
63
63
  "cors": "^2.8.5",
64
64
  "helmet": "^7.1.0",
65
+ "cookie-parser": "^1.4.6",
65
66
  "hpp": "^0.2.3",
66
67
  "express-rate-limit": "^7.1.5",
67
68
  "winston": "^3.11.0",
@@ -80,6 +81,7 @@
80
81
  "ts-node": "^10.9.2",
81
82
  "@types/node": "^20.10.5",
82
83
  "@types/express": "^4.17.21",
84
+ "@types/cookie-parser": "^1.4.6",
83
85
  "@types/cors": "^2.8.17",
84
86
  "@types/hpp": "^0.2.3",
85
87
  <% if (caching === 'Memory Cache') { %> "@types/node-cache": "^4.2.5",
@@ -1,6 +1,7 @@
1
1
  const { env } = require('./config/env');
2
2
  const express = require('express');
3
3
  const cors = require('cors');
4
+ const cookieParser = require('cookie-parser');
4
5
  <%_ if (communication === 'REST APIs' || communication === 'Kafka') { -%>const apiRoutes = require('./routes/api');<%_ } %>
5
6
  const healthRoutes = require('./routes/healthRoute');
6
7
  <%_ if (communication === 'Kafka') { -%>const { connectKafka, sendMessage } = require('./services/kafkaService');<%_ } -%>
@@ -28,6 +29,7 @@ const morgan = require('morgan');
28
29
  const { errorMiddleware } = require('./utils/errorMiddleware');
29
30
 
30
31
  app.use(cors());
32
+ app.use(cookieParser());
31
33
  app.use(express.json());
32
34
  app.use(express.urlencoded({ extended: true }));
33
35
  app.use(morgan('combined', { stream: { write: message => logger.info(message.trim()) } }));
@@ -3,6 +3,7 @@ const HTTP_STATUS = {
3
3
  CREATED: 201,
4
4
  BAD_REQUEST: 400,
5
5
  UNAUTHORIZED: 401,
6
+ FORBIDDEN: 403,
6
7
  NOT_FOUND: 404,
7
8
  INTERNAL_SERVER_ERROR: 500
8
9
  };
@@ -7,6 +7,7 @@ import cors from 'cors';
7
7
  import helmet from 'helmet';
8
8
  import hpp from 'hpp';
9
9
  import rateLimit from 'express-rate-limit';
10
+ import cookieParser from 'cookie-parser';
10
11
  import logger from '@/utils/logger';
11
12
  import morgan from 'morgan';
12
13
  import { errorMiddleware } from '@/utils/errorMiddleware';
@@ -52,6 +53,7 @@ app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE'] }));
52
53
  const limiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 100 });
53
54
  app.use(limiter);
54
55
 
56
+ app.use(cookieParser());
55
57
  app.use(express.json());
56
58
  app.use(express.urlencoded({ extended: true }));
57
59
  app.use(morgan('combined', { stream: { write: (message) => logger.info(message.trim()) } }));
@@ -3,6 +3,7 @@ export const HTTP_STATUS = {
3
3
  CREATED: 201,
4
4
  BAD_REQUEST: 400,
5
5
  UNAUTHORIZED: 401,
6
+ FORBIDDEN: 403,
6
7
  NOT_FOUND: 404,
7
8
  INTERNAL_SERVER_ERROR: 500
8
9
  } as const;