@dismissible/nestjs-jwt-auth-hook 0.0.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.
@@ -0,0 +1,283 @@
1
+ import { UnauthorizedException, ForbiddenException } from '@nestjs/common';
2
+ import { mock } from 'ts-jest-mocker';
3
+ import { JwtAuthHook } from './jwt-auth.hook';
4
+ import { JwtAuthService, IJwtValidationResult } from './jwt-auth.service';
5
+ import { JwtAuthHookConfig } from './jwt-auth-hook.config';
6
+ import { IDismissibleLogger } from '@dismissible/nestjs-logger';
7
+ import { IRequestContext } from '@dismissible/nestjs-dismissible';
8
+
9
+ describe('JwtAuthHook', () => {
10
+ let hook: JwtAuthHook;
11
+ let mockJwtAuthService: jest.Mocked<JwtAuthService>;
12
+ let mockLogger: jest.Mocked<IDismissibleLogger>;
13
+ let mockConfig: JwtAuthHookConfig;
14
+
15
+ const testItemId = 'test-item-id';
16
+ const testUserId = 'test-user-id';
17
+
18
+ beforeEach(() => {
19
+ mockJwtAuthService = mock(JwtAuthService, { failIfMockNotProvided: false });
20
+ mockLogger = mock<IDismissibleLogger>({ failIfMockNotProvided: false });
21
+ mockConfig = {
22
+ enabled: true,
23
+ wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
24
+ issuer: 'https://auth.example.com',
25
+ };
26
+
27
+ hook = new JwtAuthHook(mockJwtAuthService, mockConfig, mockLogger);
28
+ });
29
+
30
+ describe('priority', () => {
31
+ it('should default to -100 priority', () => {
32
+ expect(hook.priority).toBe(-100);
33
+ });
34
+
35
+ it('should use custom priority when provided', () => {
36
+ const customConfig = { ...mockConfig, priority: -50 };
37
+ const customHook = new JwtAuthHook(mockJwtAuthService, customConfig, mockLogger);
38
+ expect(customHook.priority).toBe(-50);
39
+ });
40
+ });
41
+
42
+ describe('onBeforeRequest', () => {
43
+ it('should skip validation and proceed when disabled', async () => {
44
+ const disabledConfig = { ...mockConfig, enabled: false };
45
+ const disabledHook = new JwtAuthHook(mockJwtAuthService, disabledConfig, mockLogger);
46
+
47
+ const context: IRequestContext = { requestId: 'req-123' };
48
+ const result = await disabledHook.onBeforeRequest(testItemId, testUserId, context);
49
+
50
+ expect(result.proceed).toBe(true);
51
+ expect(mockJwtAuthService.extractBearerToken).not.toHaveBeenCalled();
52
+ expect(mockJwtAuthService.validateToken).not.toHaveBeenCalled();
53
+ });
54
+
55
+ it('should throw UnauthorizedException when no authorization header is present', async () => {
56
+ mockJwtAuthService.extractBearerToken.mockReturnValue(null);
57
+
58
+ const context: IRequestContext = { requestId: 'req-123' };
59
+
60
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
61
+ UnauthorizedException,
62
+ );
63
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
64
+ 'Missing or invalid bearer token',
65
+ );
66
+ expect(mockJwtAuthService.extractBearerToken).toHaveBeenCalledWith(undefined);
67
+ });
68
+
69
+ it('should throw UnauthorizedException when authorization header is malformed', async () => {
70
+ mockJwtAuthService.extractBearerToken.mockReturnValue(null);
71
+
72
+ const context: IRequestContext = {
73
+ requestId: 'req-123',
74
+ authorizationHeader: 'Basic abc123',
75
+ };
76
+
77
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
78
+ UnauthorizedException,
79
+ );
80
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
81
+ 'Missing or invalid bearer token',
82
+ );
83
+ });
84
+
85
+ it('should throw UnauthorizedException when token validation fails', async () => {
86
+ const validationResult: IJwtValidationResult = {
87
+ valid: false,
88
+ error: 'Token expired',
89
+ };
90
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
91
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
92
+
93
+ const context: IRequestContext = {
94
+ requestId: 'req-123',
95
+ authorizationHeader: 'Bearer valid-token',
96
+ };
97
+
98
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
99
+ UnauthorizedException,
100
+ );
101
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
102
+ 'Token expired',
103
+ );
104
+ expect(mockJwtAuthService.validateToken).toHaveBeenCalledWith('valid-token');
105
+ });
106
+
107
+ it('should proceed when token is valid', async () => {
108
+ const matchingUserId = 'user-123';
109
+ const validationResult: IJwtValidationResult = {
110
+ valid: true,
111
+ payload: {
112
+ sub: matchingUserId,
113
+ iss: 'https://auth.example.com',
114
+ },
115
+ };
116
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
117
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
118
+
119
+ const context: IRequestContext = {
120
+ requestId: 'req-123',
121
+ authorizationHeader: 'Bearer valid-token',
122
+ };
123
+ const result = await hook.onBeforeRequest(testItemId, matchingUserId, context);
124
+
125
+ expect(result.proceed).toBe(true);
126
+ expect(result.reason).toBeUndefined();
127
+ });
128
+
129
+ it('should throw UnauthorizedException when context is missing', async () => {
130
+ mockJwtAuthService.extractBearerToken.mockReturnValue(null);
131
+
132
+ await expect(hook.onBeforeRequest(testItemId, testUserId, undefined)).rejects.toThrow(
133
+ UnauthorizedException,
134
+ );
135
+ expect(mockJwtAuthService.extractBearerToken).toHaveBeenCalledWith(undefined);
136
+ });
137
+
138
+ it('should log debug message on successful validation', async () => {
139
+ const matchingUserId = 'user-123';
140
+ const validationResult: IJwtValidationResult = {
141
+ valid: true,
142
+ payload: { sub: matchingUserId },
143
+ };
144
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
145
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
146
+
147
+ const context: IRequestContext = {
148
+ requestId: 'req-123',
149
+ authorizationHeader: 'Bearer valid-token',
150
+ };
151
+ await hook.onBeforeRequest(testItemId, matchingUserId, context);
152
+
153
+ expect(mockLogger.debug).toHaveBeenCalledWith(
154
+ 'JWT auth hook: Token validated successfully',
155
+ expect.objectContaining({
156
+ itemId: testItemId,
157
+ userId: matchingUserId,
158
+ requestId: 'req-123',
159
+ subject: matchingUserId,
160
+ }),
161
+ );
162
+ });
163
+
164
+ it('should throw ForbiddenException when userId does not match JWT sub claim', async () => {
165
+ const validationResult: IJwtValidationResult = {
166
+ valid: true,
167
+ payload: {
168
+ sub: 'different-user-id',
169
+ iss: 'https://auth.example.com',
170
+ },
171
+ };
172
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
173
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
174
+
175
+ const context: IRequestContext = {
176
+ requestId: 'req-123',
177
+ authorizationHeader: 'Bearer valid-token',
178
+ };
179
+
180
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
181
+ ForbiddenException,
182
+ );
183
+ await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
184
+ 'User ID in request does not match authenticated user',
185
+ );
186
+
187
+ expect(mockLogger.debug).toHaveBeenCalledWith(
188
+ 'JWT auth hook: User ID mismatch',
189
+ expect.objectContaining({
190
+ itemId: testItemId,
191
+ userId: testUserId,
192
+ requestId: 'req-123',
193
+ tokenSubject: 'different-user-id',
194
+ }),
195
+ );
196
+ });
197
+
198
+ it('should proceed when userId matches JWT sub claim', async () => {
199
+ const validationResult: IJwtValidationResult = {
200
+ valid: true,
201
+ payload: {
202
+ sub: testUserId,
203
+ iss: 'https://auth.example.com',
204
+ },
205
+ };
206
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
207
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
208
+
209
+ const context: IRequestContext = {
210
+ requestId: 'req-123',
211
+ authorizationHeader: 'Bearer valid-token',
212
+ };
213
+ const result = await hook.onBeforeRequest(testItemId, testUserId, context);
214
+
215
+ expect(result.proceed).toBe(true);
216
+ expect(result.reason).toBeUndefined();
217
+ });
218
+
219
+ it('should proceed when verifyUserIdMatch is disabled even if userId does not match', async () => {
220
+ const disabledVerifyConfig = { ...mockConfig, verifyUserIdMatch: false };
221
+ const disabledVerifyHook = new JwtAuthHook(
222
+ mockJwtAuthService,
223
+ disabledVerifyConfig,
224
+ mockLogger,
225
+ );
226
+
227
+ const validationResult: IJwtValidationResult = {
228
+ valid: true,
229
+ payload: {
230
+ sub: 'different-user-id',
231
+ iss: 'https://auth.example.com',
232
+ },
233
+ };
234
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
235
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
236
+
237
+ const context: IRequestContext = {
238
+ requestId: 'req-123',
239
+ authorizationHeader: 'Bearer valid-token',
240
+ };
241
+ const result = await disabledVerifyHook.onBeforeRequest(testItemId, testUserId, context);
242
+
243
+ expect(result.proceed).toBe(true);
244
+ });
245
+
246
+ it('should proceed when JWT payload does not have sub claim and verifyUserIdMatch is enabled', async () => {
247
+ const validationResult: IJwtValidationResult = {
248
+ valid: true,
249
+ payload: {
250
+ iss: 'https://auth.example.com',
251
+ // No sub claim
252
+ },
253
+ };
254
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
255
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
256
+
257
+ const context: IRequestContext = {
258
+ requestId: 'req-123',
259
+ authorizationHeader: 'Bearer valid-token',
260
+ };
261
+ const result = await hook.onBeforeRequest(testItemId, testUserId, context);
262
+
263
+ expect(result.proceed).toBe(true);
264
+ });
265
+
266
+ it('should proceed when JWT payload is undefined and verifyUserIdMatch is enabled', async () => {
267
+ const validationResult: IJwtValidationResult = {
268
+ valid: true,
269
+ // No payload
270
+ };
271
+ mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
272
+ mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
273
+
274
+ const context: IRequestContext = {
275
+ requestId: 'req-123',
276
+ authorizationHeader: 'Bearer valid-token',
277
+ };
278
+ const result = await hook.onBeforeRequest(testItemId, testUserId, context);
279
+
280
+ expect(result.proceed).toBe(true);
281
+ });
282
+ });
283
+ });
@@ -0,0 +1,99 @@
1
+ import { Injectable, Inject, UnauthorizedException, ForbiddenException } from '@nestjs/common';
2
+ import {
3
+ IDismissibleLifecycleHook,
4
+ IHookResult,
5
+ IRequestContext,
6
+ } from '@dismissible/nestjs-dismissible';
7
+ import { DISMISSIBLE_LOGGER, IDismissibleLogger } from '@dismissible/nestjs-logger';
8
+ import { JwtAuthService } from './jwt-auth.service';
9
+ import { JWT_AUTH_HOOK_CONFIG, JwtAuthHookConfig } from './jwt-auth-hook.config';
10
+
11
+ /**
12
+ * JWT authentication hook that validates bearer tokens on every request.
13
+ * This hook runs during the pre-request phase and rejects unauthorized requests.
14
+ */
15
+ @Injectable()
16
+ export class JwtAuthHook implements IDismissibleLifecycleHook {
17
+ readonly priority: number;
18
+
19
+ constructor(
20
+ private readonly jwtAuthService: JwtAuthService,
21
+ @Inject(JWT_AUTH_HOOK_CONFIG)
22
+ private readonly config: JwtAuthHookConfig,
23
+ @Inject(DISMISSIBLE_LOGGER)
24
+ private readonly logger: IDismissibleLogger,
25
+ ) {
26
+ // Default to -100 for authentication (runs early)
27
+ this.priority = config.priority ?? -100;
28
+ }
29
+
30
+ /**
31
+ * Validates the JWT bearer token from the Authorization header.
32
+ * Runs before any dismissible operation.
33
+ */
34
+ async onBeforeRequest(
35
+ itemId: string,
36
+ userId: string,
37
+ context?: IRequestContext,
38
+ ): Promise<IHookResult> {
39
+ if (!this.config.enabled) {
40
+ return { proceed: true };
41
+ }
42
+
43
+ const authorizationHeader = context?.authorizationHeader;
44
+
45
+ // Extract the bearer token
46
+ const token = this.jwtAuthService.extractBearerToken(authorizationHeader);
47
+
48
+ if (!token) {
49
+ this.logger.debug('JWT auth hook: No bearer token provided', {
50
+ itemId,
51
+ userId,
52
+ requestId: context?.requestId,
53
+ });
54
+
55
+ throw new UnauthorizedException('Missing or invalid bearer token');
56
+ }
57
+
58
+ // Validate the token
59
+ const result = await this.jwtAuthService.validateToken(token);
60
+
61
+ if (!result.valid) {
62
+ this.logger.debug('JWT auth hook: Token validation failed', {
63
+ itemId,
64
+ userId,
65
+ requestId: context?.requestId,
66
+ error: result.error,
67
+ });
68
+
69
+ throw new UnauthorizedException(result.error);
70
+ }
71
+
72
+ // Verify that the userId parameter matches the JWT subject (sub) claim
73
+ // This prevents users from accessing other users' data by changing the userId in the URL
74
+ const verifyUserIdMatch = this.config.verifyUserIdMatch ?? true;
75
+ if (verifyUserIdMatch && result.payload?.sub) {
76
+ if (result.payload.sub !== userId) {
77
+ this.logger.debug('JWT auth hook: User ID mismatch', {
78
+ itemId,
79
+ userId,
80
+ requestId: context?.requestId,
81
+ tokenSubject: result.payload.sub,
82
+ });
83
+
84
+ throw new ForbiddenException('User ID in request does not match authenticated user');
85
+ }
86
+ }
87
+
88
+ this.logger.debug('JWT auth hook: Token validated successfully', {
89
+ itemId,
90
+ userId,
91
+ requestId: context?.requestId,
92
+ subject: result.payload?.sub,
93
+ });
94
+
95
+ return {
96
+ proceed: true,
97
+ };
98
+ }
99
+ }