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 +9 -0
- package/lib/prompts.js +6 -2
- package/package.json +1 -1
- package/templates/clean-architecture/js/src/infrastructure/webserver/server.js.ejs +2 -0
- package/templates/clean-architecture/ts/src/index.ts.ejs +2 -0
- package/templates/clean-architecture/ts/src/utils/httpCodes.ts +1 -0
- package/templates/common/auth/js/controllers/authController.js.ejs +42 -12
- package/templates/common/auth/js/controllers/authController.spec.js.ejs +95 -5
- package/templates/common/auth/js/services/jwtService.js.ejs +3 -3
- package/templates/common/auth/js/services/socialAuthService.spec.js.ejs +0 -2
- package/templates/common/auth/ts/controllers/authController.spec.ts.ejs +29 -7
- package/templates/common/auth/ts/controllers/authController.ts.ejs +34 -5
- package/templates/common/eslint.config.mjs.ejs +4 -1
- package/templates/common/kafka/js/services/kafkaService.js.ejs +1 -1
- package/templates/common/package.json.ejs +2 -0
- package/templates/mvc/js/src/index.js.ejs +2 -0
- package/templates/mvc/js/src/utils/httpCodes.js +1 -0
- package/templates/mvc/ts/src/index.ts.ejs +2 -0
- package/templates/mvc/ts/src/utils/httpCodes.ts +1 -0
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 =
|
|
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 =
|
|
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,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
|
|
|
@@ -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:
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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()) } }));
|
|
@@ -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()) } }));
|