nodejs-quickstart-structure 2.1.2 → 2.2.0
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 +11 -0
- package/README.md +12 -17
- package/bin/index.js +1 -0
- package/lib/generator.js +1 -1
- package/lib/modules/app-setup.js +16 -0
- package/lib/modules/auth-setup.js +46 -4
- package/lib/prompts.js +44 -4
- package/package.json +1 -1
- package/templates/clean-architecture/js/src/infrastructure/config/env.js.ejs +12 -2
- package/templates/clean-architecture/js/src/infrastructure/repositories/UserRepository.js.ejs +27 -0
- package/templates/clean-architecture/js/src/infrastructure/repositories/UserRepository.spec.js.ejs +24 -0
- package/templates/clean-architecture/js/src/infrastructure/webserver/server.js.ejs +3 -1
- package/templates/clean-architecture/ts/src/config/env.ts.ejs +12 -2
- package/templates/clean-architecture/ts/src/domain/user.ts.ejs +14 -0
- package/templates/clean-architecture/ts/src/infrastructure/repositories/UserRepository.spec.ts.ejs +24 -0
- package/templates/clean-architecture/ts/src/infrastructure/repositories/userRepository.ts.ejs +43 -45
- package/templates/clean-architecture/ts/src/interfaces/graphql/resolvers/user.resolvers.ts.ejs +5 -5
- package/templates/common/.env.example.ejs +10 -0
- package/templates/common/README.md.ejs +65 -14
- package/templates/common/auth/js/controllers/authController.js.ejs +326 -13
- package/templates/common/auth/js/controllers/authController.spec.js.ejs +237 -51
- package/templates/common/auth/js/middleware/authMiddleware.js.ejs +10 -6
- package/templates/common/auth/js/routes/authRoutes.js.ejs +11 -0
- package/templates/common/auth/js/services/jwtService.js.ejs +3 -3
- package/templates/common/auth/js/services/jwtService.spec.js.ejs +30 -0
- package/templates/common/auth/js/services/socialAuthService.js.ejs +175 -0
- package/templates/common/auth/js/services/socialAuthService.spec.js.ejs +194 -0
- package/templates/common/auth/js/usecases/SocialLoginUseCase.js.ejs +114 -0
- package/templates/common/auth/js/usecases/SocialLoginUseCase.spec.js.ejs +143 -0
- package/templates/common/auth/ts/controllers/authController.spec.ts.ejs +344 -64
- package/templates/common/auth/ts/controllers/authController.ts.ejs +341 -9
- package/templates/common/auth/ts/middleware/authMiddleware.ts.ejs +10 -6
- package/templates/common/auth/ts/routes/authRoutes.ts.ejs +11 -0
- package/templates/common/auth/ts/services/jwtService.spec.ts.ejs +18 -0
- package/templates/common/auth/ts/services/jwtService.ts.ejs +3 -3
- package/templates/common/auth/ts/services/socialAuthService.spec.ts.ejs +187 -0
- package/templates/common/auth/ts/services/socialAuthService.ts.ejs +189 -0
- package/templates/common/auth/ts/usecases/SocialLoginUseCase.spec.ts.ejs +143 -0
- package/templates/common/auth/ts/usecases/SocialLoginUseCase.ts.ejs +117 -0
- package/templates/common/database/js/models/User.js.ejs +13 -5
- package/templates/common/database/js/models/User.js.mongoose.ejs +15 -1
- package/templates/common/database/ts/models/User.ts.ejs +23 -7
- package/templates/common/database/ts/models/User.ts.mongoose.ejs +18 -2
- package/templates/common/docker-compose.yml.ejs +21 -0
- package/templates/common/ecosystem.config.js.ejs +10 -0
- package/templates/common/jest.config.js.ejs +1 -1
- package/templates/common/kafka/js/services/kafkaService.js.ejs +1 -1
- package/templates/common/kafka/ts/services/kafkaService.ts.ejs +1 -1
- package/templates/common/package.json.ejs +2 -0
- package/templates/common/src/tests/e2e/e2e.users.test.js.ejs +13 -1
- package/templates/common/src/tests/e2e/e2e.users.test.ts.ejs +13 -1
- package/templates/common/swagger.yml.ejs +62 -3
- package/templates/common/views/ejs/login.ejs.ejs +84 -0
- package/templates/common/views/ejs/signup.ejs.ejs +84 -0
- package/templates/common/views/pug/login.pug.ejs +78 -0
- package/templates/common/views/pug/signup.pug.ejs +78 -0
- package/templates/db/mysql/V1__Initial_Setup.sql.ejs +3 -1
- package/templates/db/postgres/V1__Initial_Setup.sql.ejs +3 -1
- package/templates/mvc/js/src/config/env.js.ejs +12 -2
- package/templates/mvc/js/src/controllers/userController.js.ejs +1 -1
- package/templates/mvc/js/src/graphql/resolvers/user.resolvers.js.ejs +4 -3
- package/templates/mvc/ts/src/config/env.ts.ejs +12 -2
- package/templates/mvc/ts/src/controllers/userController.ts.ejs +1 -0
- package/templates/mvc/ts/src/graphql/resolvers/user.resolvers.ts.ejs +5 -5
- package/templates/mvc/ts/src/index.ts.ejs +2 -1
- package/templates/clean-architecture/ts/src/domain/user.ts +0 -9
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
2
|
-
const AuthController = require('@/interfaces/controllers/auth/authController');
|
|
3
|
-
const JwtService = require('@/infrastructure/auth/jwtService');
|
|
4
|
-
<%_ } else { _%>
|
|
5
|
-
const AuthController = require('@/controllers/authController');
|
|
6
|
-
const JwtService = require('@/services/jwtService');
|
|
7
|
-
<%_ } _%>
|
|
8
|
-
const HTTP_STATUS = require('@/utils/httpCodes');
|
|
9
|
-
const bcrypt = require('bcryptjs');
|
|
10
|
-
|
|
11
|
-
jest.mock('bcryptjs');
|
|
12
|
-
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
13
2
|
jest.mock('@/infrastructure/auth/jwtService');
|
|
3
|
+
<%_ if (socialAuth && socialAuth.filter(a => a !== 'None').length > 0) { _%>
|
|
4
|
+
jest.mock('@/infrastructure/auth/socialAuthService');
|
|
5
|
+
jest.mock('@/usecases/auth/socialLoginUseCase');
|
|
6
|
+
jest.mock('@/infrastructure/repositories/UserRepository');
|
|
7
|
+
<%_ } _%>
|
|
14
8
|
jest.mock('@/infrastructure/database/models/User', () => ({
|
|
15
9
|
findOne: jest.fn()
|
|
16
10
|
}));
|
|
17
|
-
const User = require('@/infrastructure/database/models/User');
|
|
18
11
|
<%_ } else { _%>
|
|
19
12
|
jest.mock('@/services/jwtService');
|
|
13
|
+
<%_ if (socialAuth && socialAuth.filter(a => a !== 'None').length > 0) { _%>
|
|
14
|
+
jest.mock('@/services/socialAuthService');
|
|
15
|
+
<%_ } _%>
|
|
20
16
|
jest.mock('@/models/User', () => ({
|
|
21
17
|
findOne: jest.fn()
|
|
22
18
|
}));
|
|
19
|
+
<%_ } _%>
|
|
20
|
+
jest.mock('bcryptjs');
|
|
21
|
+
|
|
22
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
23
|
+
const AuthController = require('@/interfaces/controllers/auth/authController');
|
|
24
|
+
const JwtService = require('@/infrastructure/auth/jwtService');
|
|
25
|
+
<%_ if (socialAuth && socialAuth.filter(a => a !== 'None').length > 0) { _%>
|
|
26
|
+
const { SocialLoginUseCase } = require('@/usecases/auth/socialLoginUseCase');
|
|
27
|
+
<%_ } _%>
|
|
28
|
+
const User = require('@/infrastructure/database/models/User');
|
|
29
|
+
<%_ } else { _%>
|
|
30
|
+
const AuthController = require('@/controllers/authController');
|
|
31
|
+
const JwtService = require('@/services/jwtService');
|
|
32
|
+
<%_ if (socialAuth && socialAuth.filter(a => a !== 'None').length > 0) { _%>
|
|
33
|
+
const { SocialAuthService } = require('@/services/socialAuthService');
|
|
34
|
+
<%_ } _%>
|
|
23
35
|
const User = require('@/models/User');
|
|
24
36
|
<%_ } _%>
|
|
37
|
+
const HTTP_STATUS = require('@/utils/httpCodes');
|
|
38
|
+
const bcrypt = require('bcryptjs');
|
|
39
|
+
|
|
25
40
|
<%_ if (caching !== 'None') { _%>
|
|
26
41
|
<%_ const cachePath = (architecture === "MVC") ? (caching === "Redis" ? "@/config/redisClient" : "@/config/memoryCache") : (caching === "Redis" ? "@/infrastructure/caching/redisClient" : "@/infrastructure/caching/memoryCache"); _%>
|
|
27
42
|
jest.mock('<%= cachePath %>', () => ({
|
|
@@ -42,11 +57,14 @@ describe('AuthController', () => {
|
|
|
42
57
|
controller = new AuthController();
|
|
43
58
|
mockReq = {
|
|
44
59
|
body: {},
|
|
45
|
-
headers: {}
|
|
60
|
+
headers: {},
|
|
61
|
+
query: {}
|
|
46
62
|
};
|
|
47
63
|
mockRes = {
|
|
48
64
|
status: jest.fn().mockReturnThis(),
|
|
49
|
-
json: jest.fn()
|
|
65
|
+
json: jest.fn(),
|
|
66
|
+
cookie: jest.fn(),
|
|
67
|
+
redirect: jest.fn()
|
|
50
68
|
};
|
|
51
69
|
next = jest.fn();
|
|
52
70
|
jest.clearAllMocks();
|
|
@@ -62,7 +80,6 @@ describe('AuthController', () => {
|
|
|
62
80
|
JwtService.generateRefreshToken.mockReturnValue('mock-refresh-token');
|
|
63
81
|
JwtService.decodeToken.mockReturnValue({ jti: 'test-jti' });
|
|
64
82
|
|
|
65
|
-
// Mock cacheService
|
|
66
83
|
<% if (caching !== 'None') { %>
|
|
67
84
|
cacheService.get.mockResolvedValue([]);
|
|
68
85
|
cacheService.set.mockResolvedValue();<% } %>
|
|
@@ -73,7 +90,7 @@ describe('AuthController', () => {
|
|
|
73
90
|
expect(mockRes.json).toHaveBeenCalledWith({ token: 'mock-access-token', accessToken: 'mock-access-token', refreshToken: 'mock-refresh-token' });
|
|
74
91
|
});
|
|
75
92
|
|
|
76
|
-
it('should return 401
|
|
93
|
+
it('should return 401 if user not found', async () => {
|
|
77
94
|
mockReq.body = { email: 'wrong@test.com', password: 'password123' };
|
|
78
95
|
User.findOne.mockResolvedValue(null);
|
|
79
96
|
|
|
@@ -82,7 +99,7 @@ describe('AuthController', () => {
|
|
|
82
99
|
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.UNAUTHORIZED);
|
|
83
100
|
});
|
|
84
101
|
|
|
85
|
-
it('should return 401
|
|
102
|
+
it('should return 401 if password invalid', async () => {
|
|
86
103
|
const user = { id: 1, email: 'test@test.com', password: 'hashedpassword' };
|
|
87
104
|
mockReq.body = { email: 'test@test.com', password: 'wrongpassword' };
|
|
88
105
|
User.findOne.mockResolvedValue(user);
|
|
@@ -92,57 +109,226 @@ describe('AuthController', () => {
|
|
|
92
109
|
|
|
93
110
|
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.UNAUTHORIZED);
|
|
94
111
|
});
|
|
112
|
+
|
|
113
|
+
it('should call next on error', async () => {
|
|
114
|
+
const error = new Error('Database error');
|
|
115
|
+
User.findOne.mockRejectedValue(error);
|
|
116
|
+
mockReq.body = { email: 'test@test.com', password: 'password123' };
|
|
117
|
+
|
|
118
|
+
await controller.login(mockReq, mockRes, next);
|
|
119
|
+
|
|
120
|
+
expect(next).toHaveBeenCalledWith(error);
|
|
121
|
+
});
|
|
95
122
|
});
|
|
96
123
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
JwtService.verifyRefreshToken.mockReturnValue(decoded);
|
|
102
|
-
JwtService.generateToken.mockReturnValue('new-access');
|
|
103
|
-
JwtService.generateRefreshToken.mockReturnValue('new-refresh');
|
|
104
|
-
JwtService.decodeToken.mockReturnValue({ jti: 'new-jti' });
|
|
124
|
+
describe('refresh', () => {
|
|
125
|
+
it('should return 401 if refresh token is invalid', async () => {
|
|
126
|
+
mockReq.body = { refreshToken: 'invalid-token' };
|
|
127
|
+
JwtService.verifyRefreshToken.mockReturnValue(null);
|
|
105
128
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
await controller.refresh(mockReq, mockRes, next);
|
|
130
|
+
|
|
131
|
+
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.UNAUTHORIZED);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return new tokens if refresh token is valid', async () => {
|
|
135
|
+
mockReq.body = { refreshToken: 'valid-token' };
|
|
136
|
+
const decoded = { id: '1', email: 'test@test.com', jti: 'test-jti' };
|
|
137
|
+
JwtService.verifyRefreshToken.mockReturnValue(decoded);
|
|
138
|
+
JwtService.generateRefreshToken.mockReturnValue('new-refresh-token');
|
|
139
|
+
JwtService.generateToken.mockReturnValue('new-access-token');
|
|
140
|
+
JwtService.decodeToken.mockReturnValue({ jti: 'new-jti' });
|
|
141
|
+
|
|
142
|
+
<% if (caching !== 'None') { %>
|
|
143
|
+
cacheService.get.mockResolvedValue(['test-jti']);
|
|
144
|
+
<% } else { %>
|
|
145
|
+
JwtService.activeRefreshTokens.set('1', ['test-jti']);
|
|
146
|
+
<% } %>
|
|
147
|
+
|
|
148
|
+
await controller.refresh(mockReq, mockRes, next);
|
|
149
|
+
|
|
150
|
+
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'new-access-token' }));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return 401 if token theft detected', async () => {
|
|
154
|
+
mockReq.body = { refreshToken: 'stolen-token' };
|
|
155
|
+
const decoded = { id: '1', email: 'test@test.com', jti: 'stolen-jti' };
|
|
156
|
+
JwtService.verifyRefreshToken.mockReturnValue(decoded);
|
|
157
|
+
<% if (caching !== 'None') { %>
|
|
158
|
+
cacheService.get.mockResolvedValue(['other-jti']);
|
|
159
|
+
cacheService.del.mockResolvedValue();
|
|
160
|
+
<% } %>
|
|
161
|
+
|
|
162
|
+
await controller.refresh(mockReq, mockRes, next);
|
|
163
|
+
|
|
164
|
+
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.UNAUTHORIZED);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should call next on refresh error', async () => {
|
|
168
|
+
const error = new Error('Redis error');
|
|
169
|
+
mockReq.body = { refreshToken: 'valid-token' };
|
|
170
|
+
JwtService.verifyRefreshToken.mockImplementation(() => { throw error; });
|
|
171
|
+
|
|
172
|
+
await controller.refresh(mockReq, mockRes, next);
|
|
173
|
+
|
|
174
|
+
expect(next).toHaveBeenCalledWith(error);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('logout', () => {
|
|
179
|
+
it('should return 400 if no token provided', async () => {
|
|
180
|
+
mockReq.headers = {};
|
|
181
|
+
await controller.logout(mockReq, mockRes, next);
|
|
182
|
+
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.BAD_REQUEST);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should logout successfully', async () => {
|
|
186
|
+
mockReq.headers = { authorization: 'Bearer valid-token' };
|
|
187
|
+
mockReq.body = { refreshToken: 'valid-refresh-token' };
|
|
188
|
+
JwtService.decodeToken.mockReturnValueOnce({ jti: 'access-jti', exp: Math.floor(Date.now() / 1000) + 3600 })
|
|
189
|
+
.mockReturnValueOnce({ id: '1', jti: 'refresh-jti' });
|
|
190
|
+
|
|
191
|
+
<% if (caching !== 'None') { %>
|
|
192
|
+
cacheService.get.mockResolvedValue(['refresh-jti']);
|
|
193
|
+
<% } else { %>
|
|
194
|
+
JwtService.activeRefreshTokens.set('1', ['refresh-jti']);
|
|
195
|
+
<% } %>
|
|
196
|
+
|
|
197
|
+
await controller.logout(mockReq, mockRes, next);
|
|
198
|
+
|
|
199
|
+
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Logged out successfully' });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should handle logout even if no refresh token provided', async () => {
|
|
203
|
+
mockReq.headers = { authorization: 'Bearer valid-token' };
|
|
204
|
+
mockReq.body = {};
|
|
205
|
+
JwtService.decodeToken.mockReturnValue({ jti: 'access-jti', exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
206
|
+
|
|
207
|
+
await controller.logout(mockReq, mockRes, next);
|
|
208
|
+
|
|
209
|
+
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Logged out successfully' });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should call next on logout error', async () => {
|
|
213
|
+
const error = new Error('Logout error');
|
|
214
|
+
mockReq.headers = { authorization: 'Bearer valid-token' };
|
|
215
|
+
JwtService.decodeToken.mockImplementation(() => { throw error; });
|
|
216
|
+
|
|
217
|
+
await controller.logout(mockReq, mockRes, next);
|
|
218
|
+
|
|
219
|
+
expect(next).toHaveBeenCalledWith(error);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
<% if (socialAuth && socialAuth.filter(a => a !== 'None').length > 0) { -%>
|
|
224
|
+
describe('socialExchange', () => {
|
|
225
|
+
it('should return 400 if code or provider missing', async () => {
|
|
226
|
+
mockReq.body = {};
|
|
227
|
+
await controller.socialExchange(mockReq, mockRes, next);
|
|
228
|
+
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.BAD_REQUEST);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should exchange code for JWT tokens', async () => {
|
|
232
|
+
mockReq.body = { code: 'test-code', provider: 'Google' };
|
|
233
|
+
const user = { id: 1, email: 'social@test.com' };
|
|
234
|
+
|
|
235
|
+
<% if (architecture === 'Clean Architecture') { %>
|
|
236
|
+
const mockUseCase = {
|
|
237
|
+
execute: jest.fn().mockResolvedValue({
|
|
238
|
+
user,
|
|
239
|
+
accessToken: 'mock-token',
|
|
240
|
+
refreshToken: 'mock-refresh-token'
|
|
241
|
+
})
|
|
242
|
+
};
|
|
243
|
+
SocialLoginUseCase.mockImplementation(() => mockUseCase);
|
|
109
244
|
<% } else { %>
|
|
110
|
-
|
|
245
|
+
SocialAuthService.getGoogleProfile.mockResolvedValue({ email: 'social@test.com', id: 'google-id', name: 'Google User' });
|
|
246
|
+
User.findOne.mockResolvedValue(user);
|
|
247
|
+
JwtService.generateToken.mockReturnValue('mock-token');
|
|
248
|
+
JwtService.generateRefreshToken.mockReturnValue('mock-refresh-token');
|
|
249
|
+
<% } %>
|
|
250
|
+
JwtService.decodeToken.mockReturnValue({ jti: 'test-jti' });
|
|
111
251
|
|
|
112
|
-
await controller.
|
|
252
|
+
await controller.socialExchange(mockReq, mockRes, next);
|
|
113
253
|
|
|
114
|
-
expect(
|
|
115
|
-
expect(mockRes.json).toHaveBeenCalledWith({ accessToken: 'new-access', refreshToken: 'new-refresh' });
|
|
254
|
+
expect(mockRes.json).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'mock-token' }));
|
|
116
255
|
});
|
|
117
256
|
|
|
118
|
-
it('should
|
|
119
|
-
mockReq.body = {
|
|
120
|
-
|
|
121
|
-
|
|
257
|
+
it('should return 400 for invalid provider', async () => {
|
|
258
|
+
mockReq.body = { code: 'test-code', provider: 'Invalid' };
|
|
259
|
+
await controller.socialExchange(mockReq, mockRes, next);
|
|
260
|
+
expect(mockRes.status).toHaveBeenCalledWith(HTTP_STATUS.BAD_REQUEST);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
122
263
|
|
|
123
|
-
|
|
124
|
-
|
|
264
|
+
describe('social redirect methods', () => {
|
|
265
|
+
<% if (socialAuth.includes('Google')) { %>
|
|
266
|
+
it('googleLogin should redirect to Google', async () => {
|
|
267
|
+
await controller.googleLogin(mockReq, mockRes);
|
|
268
|
+
expect(mockRes.redirect).toHaveBeenCalledWith(expect.stringContaining('accounts.google.com'));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('googleCallback should handle Google callback', async () => {
|
|
272
|
+
mockReq.query = { code: 'test-code' };
|
|
273
|
+
const user = { id: 1, email: 'google@test.com' };
|
|
274
|
+
|
|
275
|
+
<% if (architecture === 'Clean Architecture') { %>
|
|
276
|
+
const mockUseCase = {
|
|
277
|
+
execute: jest.fn().mockResolvedValue({
|
|
278
|
+
user,
|
|
279
|
+
accessToken: 'mock-token',
|
|
280
|
+
refreshToken: 'mock-refresh-token'
|
|
281
|
+
})
|
|
282
|
+
};
|
|
283
|
+
SocialLoginUseCase.mockImplementation(() => mockUseCase);
|
|
125
284
|
<% } else { %>
|
|
126
|
-
|
|
285
|
+
SocialAuthService.getGoogleProfile.mockResolvedValue({ email: 'google@test.com' });
|
|
286
|
+
User.findOne.mockResolvedValue(user);
|
|
287
|
+
JwtService.generateToken.mockReturnValue('mock-token');
|
|
288
|
+
JwtService.generateRefreshToken.mockReturnValue('mock-refresh-token');
|
|
289
|
+
<% } %>
|
|
290
|
+
JwtService.decodeToken.mockReturnValue({ jti: 'test-jti' });
|
|
127
291
|
|
|
128
|
-
await controller.
|
|
292
|
+
await controller.googleCallback(mockReq, mockRes, next);
|
|
129
293
|
|
|
130
|
-
expect(mockRes.
|
|
294
|
+
expect(mockRes.cookie).toHaveBeenCalledWith('accessToken', 'mock-token', expect.any(Object));
|
|
295
|
+
expect(mockRes.redirect).toHaveBeenCalledWith('/');
|
|
131
296
|
});
|
|
132
|
-
|
|
297
|
+
<% } %>
|
|
133
298
|
|
|
134
|
-
|
|
135
|
-
it('should
|
|
136
|
-
mockReq
|
|
137
|
-
|
|
299
|
+
<% if (socialAuth.includes('GitHub')) { %>
|
|
300
|
+
it('githubLogin should redirect to GitHub', async () => {
|
|
301
|
+
await controller.githubLogin(mockReq, mockRes);
|
|
302
|
+
expect(mockRes.redirect).toHaveBeenCalledWith(expect.stringContaining('github.com'));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('githubCallback should handle GitHub callback', async () => {
|
|
306
|
+
mockReq.query = { code: 'test-code' };
|
|
307
|
+
const user = { id: 1, email: 'github@test.com' };
|
|
138
308
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
.
|
|
309
|
+
<% if (architecture === 'Clean Architecture') { %>
|
|
310
|
+
const mockUseCase = {
|
|
311
|
+
execute: jest.fn().mockResolvedValue({
|
|
312
|
+
user,
|
|
313
|
+
accessToken: 'mock-token',
|
|
314
|
+
refreshToken: 'mock-refresh-token'
|
|
315
|
+
})
|
|
316
|
+
};
|
|
317
|
+
SocialLoginUseCase.mockImplementation(() => mockUseCase);
|
|
318
|
+
<% } else { %>
|
|
319
|
+
SocialAuthService.getGithubProfile.mockResolvedValue({ email: 'github@test.com' });
|
|
320
|
+
User.findOne.mockResolvedValue(user);
|
|
321
|
+
JwtService.generateToken.mockReturnValue('mock-token');
|
|
322
|
+
JwtService.generateRefreshToken.mockReturnValue('mock-refresh-token');
|
|
323
|
+
<% } %>
|
|
324
|
+
JwtService.decodeToken.mockReturnValue({ jti: 'test-jti' });
|
|
142
325
|
|
|
143
|
-
await controller.
|
|
326
|
+
await controller.githubCallback(mockReq, mockRes, next);
|
|
144
327
|
|
|
145
|
-
expect(mockRes.
|
|
328
|
+
expect(mockRes.cookie).toHaveBeenCalledWith('accessToken', 'mock-token', expect.any(Object));
|
|
329
|
+
expect(mockRes.redirect).toHaveBeenCalledWith('/');
|
|
146
330
|
});
|
|
331
|
+
<% } %>
|
|
147
332
|
});
|
|
333
|
+
<% } -%>
|
|
148
334
|
});
|
|
@@ -27,28 +27,32 @@ const authMiddleware = async (req, res, next) => {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (decoded.jti) {
|
|
30
|
-
<%_ if (caching !== 'None') {
|
|
30
|
+
<%_ if (caching !== 'None') { _%>
|
|
31
31
|
const isBlacklisted = await cacheService.get(`blacklist:${decoded.jti}`);
|
|
32
32
|
if (isBlacklisted) {
|
|
33
33
|
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ message: 'Token revoked' });
|
|
34
|
-
}
|
|
34
|
+
}
|
|
35
|
+
<%_ } else { _%>
|
|
35
36
|
const expiryDate = JwtService.blacklistedTokens.get(decoded.jti);
|
|
36
37
|
if (expiryDate && Date.now() < expiryDate) {
|
|
37
38
|
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ message: 'Token revoked' });
|
|
38
|
-
}
|
|
39
|
+
}
|
|
40
|
+
<%_ } _%>
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
if (decoded.sid) {
|
|
42
|
-
<%_ if (caching !== 'None') { %>
|
|
44
|
+
<%_ if (caching !== 'None') { _%>
|
|
43
45
|
const cacheKey = `refresh_tokens:${decoded.id}`;
|
|
44
46
|
const activeTokens = await cacheService.get(cacheKey) || [];
|
|
45
47
|
if (!activeTokens.includes(decoded.sid)) {
|
|
46
48
|
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ message: 'Session expired' });
|
|
47
|
-
}
|
|
49
|
+
}
|
|
50
|
+
<%_ } else { _%>
|
|
48
51
|
const activeTokens = JwtService.activeRefreshTokens.get(String(decoded.id)) || [];
|
|
49
52
|
if (!activeTokens.includes(decoded.sid)) {
|
|
50
53
|
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ message: 'Session expired' });
|
|
51
|
-
}
|
|
54
|
+
}
|
|
55
|
+
<%_ } _%>
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
req.user = decoded;
|
|
@@ -12,5 +12,16 @@ const authController = new AuthController();
|
|
|
12
12
|
router.post('/login', (req, res, next) => authController.login(req, res, next));
|
|
13
13
|
router.post('/refresh', (req, res, next) => authController.refresh(req, res, next));
|
|
14
14
|
router.post('/logout', authMiddleware, (req, res, next) => authController.logout(req, res, next));
|
|
15
|
+
<%_ if (socialAuth && socialAuth.filter(a => a !== 'None').length > 0) { _%>
|
|
16
|
+
router.post('/social/exchange', (req, res, next) => authController.socialExchange(req, res, next));
|
|
17
|
+
<%_ if (socialAuth.includes('Google')) { _%>
|
|
18
|
+
router.get('/google', (req, res) => authController.googleLogin(req, res));
|
|
19
|
+
router.get('/google/callback', (req, res, next) => authController.googleCallback(req, res, next));
|
|
20
|
+
<%_ } _%>
|
|
21
|
+
<%_ if (socialAuth.includes('GitHub')) { _%>
|
|
22
|
+
router.get('/github', (req, res) => authController.githubLogin(req, res));
|
|
23
|
+
router.get('/github/callback', (req, res, next) => authController.githubCallback(req, res, next));
|
|
24
|
+
<%_ } _%>
|
|
25
|
+
<%_ } _%>
|
|
15
26
|
|
|
16
27
|
module.exports = router;
|
|
@@ -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 (error) {
|
|
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 (error) {
|
|
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 (error) {
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -58,6 +58,12 @@ describe('JwtService', () => {
|
|
|
58
58
|
expect(jwt.verify).toHaveBeenCalledWith(token, secret);
|
|
59
59
|
expect(result).toEqual(payload);
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
it('should return null for an invalid token', () => {
|
|
63
|
+
jwt.verify.mockImplementation(() => { throw new Error('Invalid token'); });
|
|
64
|
+
const result = JwtService.verifyToken(token);
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
});
|
|
61
67
|
});
|
|
62
68
|
|
|
63
69
|
describe('verifyRefreshToken', () => {
|
|
@@ -69,6 +75,12 @@ describe('JwtService', () => {
|
|
|
69
75
|
expect(jwt.verify).toHaveBeenCalledWith(token, 'test-refresh-secret');
|
|
70
76
|
expect(result).toEqual(payload);
|
|
71
77
|
});
|
|
78
|
+
|
|
79
|
+
it('should return null for an invalid refresh token', () => {
|
|
80
|
+
jwt.verify.mockImplementation(() => { throw new Error('Invalid token'); });
|
|
81
|
+
const result = JwtService.verifyRefreshToken(token);
|
|
82
|
+
expect(result).toBeNull();
|
|
83
|
+
});
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
describe('decodeToken', () => {
|
|
@@ -80,5 +92,23 @@ describe('JwtService', () => {
|
|
|
80
92
|
expect(jwt.decode).toHaveBeenCalledWith(token);
|
|
81
93
|
expect(result).toEqual(payload);
|
|
82
94
|
});
|
|
95
|
+
|
|
96
|
+
it('should return null if decode fails', () => {
|
|
97
|
+
jwt.decode.mockImplementation(() => { throw new Error('Decode failed'); });
|
|
98
|
+
const result = JwtService.decodeToken(token);
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('fallback values', () => {
|
|
104
|
+
it('should use default values if env not set', () => {
|
|
105
|
+
delete process.env.JWT_SECRET;
|
|
106
|
+
delete process.env.JWT_REFRESH_SECRET;
|
|
107
|
+
delete process.env.JWT_EXPIRES_IN;
|
|
108
|
+
delete process.env.JWT_REFRESH_EXPIRES_IN;
|
|
109
|
+
|
|
110
|
+
expect(JwtService.SECRET).toBeDefined();
|
|
111
|
+
expect(JwtService.REFRESH_SECRET).toBeDefined();
|
|
112
|
+
});
|
|
83
113
|
});
|
|
84
114
|
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<% if (architecture === 'MVC') { -%>
|
|
2
|
+
const logger = require('../utils/logger');
|
|
3
|
+
<% } else { -%>
|
|
4
|
+
const logger = require('../log/logger');
|
|
5
|
+
<% } -%>
|
|
6
|
+
const axios = require('axios');
|
|
7
|
+
|
|
8
|
+
<% if (architecture === 'Clean Architecture') { -%>
|
|
9
|
+
// Provider implementations for Clean Architecture
|
|
10
|
+
<% if (socialAuth.includes('Google')) { -%>
|
|
11
|
+
class GoogleProvider {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.name = 'Google';
|
|
14
|
+
}
|
|
15
|
+
async getProfile(code, redirectUri) {
|
|
16
|
+
try {
|
|
17
|
+
const params = new URLSearchParams();
|
|
18
|
+
params.append('code', code);
|
|
19
|
+
params.append('client_id', process.env.GOOGLE_CLIENT_ID);
|
|
20
|
+
params.append('client_secret', process.env.GOOGLE_CLIENT_SECRET);
|
|
21
|
+
params.append('redirect_uri', redirectUri);
|
|
22
|
+
params.append('grant_type', 'authorization_code');
|
|
23
|
+
|
|
24
|
+
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
|
|
25
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { access_token } = tokenResponse.data;
|
|
29
|
+
const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
30
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
id: profileResponse.data.id,
|
|
35
|
+
email: profileResponse.data.email,
|
|
36
|
+
name: profileResponse.data.name,
|
|
37
|
+
picture: profileResponse.data.picture
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
logger.error('Google OAuth error:', error.response?.data || error.message);
|
|
41
|
+
throw new Error('Failed to authenticate with Google');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
<% } -%>
|
|
46
|
+
|
|
47
|
+
<% if (socialAuth.includes('GitHub')) { -%>
|
|
48
|
+
class GitHubProvider {
|
|
49
|
+
constructor() {
|
|
50
|
+
this.name = 'GitHub';
|
|
51
|
+
}
|
|
52
|
+
async getProfile(code) {
|
|
53
|
+
try {
|
|
54
|
+
const tokenResponse = await axios.post(
|
|
55
|
+
'https://github.com/login/oauth/access_token',
|
|
56
|
+
{
|
|
57
|
+
client_id: process.env.GITHUB_CLIENT_ID,
|
|
58
|
+
client_secret: process.env.GITHUB_CLIENT_SECRET,
|
|
59
|
+
code,
|
|
60
|
+
},
|
|
61
|
+
{ headers: { Accept: 'application/json' } }
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const { access_token } = tokenResponse.data;
|
|
65
|
+
if (!access_token) throw new Error('No access token returned from GitHub');
|
|
66
|
+
|
|
67
|
+
const [profileRes, emailsRes] = await Promise.all([
|
|
68
|
+
axios.get('https://api.github.com/user', { headers: { Authorization: `Bearer ${access_token}` } }),
|
|
69
|
+
axios.get('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${access_token}` } })
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const email = emailsRes.data.find((e) => e.primary)?.email || emailsRes.data[0]?.email;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: profileRes.data.id.toString(),
|
|
76
|
+
email,
|
|
77
|
+
name: profileRes.data.name || profileRes.data.login,
|
|
78
|
+
};
|
|
79
|
+
} catch (error) {
|
|
80
|
+
logger.error('GitHub OAuth error:', error.response?.data || error.message);
|
|
81
|
+
throw new Error('Failed to authenticate with GitHub');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
<% } -%>
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
<% if (socialAuth.includes('Google')) { %>GoogleProvider,<% } %>
|
|
89
|
+
<% if (socialAuth.includes('GitHub')) { %>GitHubProvider,<% } %>
|
|
90
|
+
};
|
|
91
|
+
<% } else { -%>
|
|
92
|
+
class SocialAuthService {
|
|
93
|
+
<% if (socialAuth.includes('Google')) { -%>
|
|
94
|
+
static async getGoogleProfile(code, redirectUri) {
|
|
95
|
+
try {
|
|
96
|
+
const params = new URLSearchParams();
|
|
97
|
+
params.append('code', code);
|
|
98
|
+
params.append('client_id', process.env.GOOGLE_CLIENT_ID);
|
|
99
|
+
params.append('client_secret', process.env.GOOGLE_CLIENT_SECRET);
|
|
100
|
+
params.append('redirect_uri', redirectUri);
|
|
101
|
+
params.append('grant_type', 'authorization_code');
|
|
102
|
+
|
|
103
|
+
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
|
|
104
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const { access_token } = tokenResponse.data;
|
|
108
|
+
|
|
109
|
+
const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
110
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: profileResponse.data.id,
|
|
115
|
+
email: profileResponse.data.email,
|
|
116
|
+
name: profileResponse.data.name,
|
|
117
|
+
picture: profileResponse.data.picture
|
|
118
|
+
};
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (axios.isAxiosError(error)) {
|
|
121
|
+
const detail = error.response?.data;
|
|
122
|
+
logger.error('Google OAuth error:', detail || error.message);
|
|
123
|
+
if (detail?.error === 'invalid_grant') {
|
|
124
|
+
logger.error('HINT: The code is likely expired or already used. Get a new one!');
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
logger.error('Google OAuth error:', error.message);
|
|
128
|
+
}
|
|
129
|
+
throw new Error('Failed to authenticate with Google');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
<% } -%>
|
|
133
|
+
|
|
134
|
+
<% if (socialAuth.includes('GitHub')) { -%>
|
|
135
|
+
static async getGithubProfile(code) {
|
|
136
|
+
try {
|
|
137
|
+
const tokenResponse = await axios.post(
|
|
138
|
+
'https://github.com/login/oauth/access_token',
|
|
139
|
+
{
|
|
140
|
+
client_id: process.env.GITHUB_CLIENT_ID,
|
|
141
|
+
client_secret: process.env.GITHUB_CLIENT_SECRET,
|
|
142
|
+
code,
|
|
143
|
+
},
|
|
144
|
+
{ headers: { Accept: 'application/json' } }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const { access_token } = tokenResponse.data;
|
|
148
|
+
if (!access_token) {
|
|
149
|
+
throw new Error('No access token returned from GitHub');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const profileResponse = await axios.get('https://api.github.com/user', {
|
|
153
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const emailsResponse = await axios.get('https://api.github.com/user/emails', {
|
|
157
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const primaryEmail = emailsResponse.data.find((e) => e.primary)?.email || emailsResponse.data[0]?.email;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
id: profileResponse.data.id.toString(),
|
|
164
|
+
email: primaryEmail,
|
|
165
|
+
name: profileResponse.data.name || profileResponse.data.login,
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.error('GitHub OAuth error:', error.response?.data || error.message);
|
|
169
|
+
throw new Error('Failed to authenticate with GitHub');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
<% } -%>
|
|
173
|
+
}
|
|
174
|
+
module.exports = { SocialAuthService };
|
|
175
|
+
<% } -%>
|