@dismissible/nestjs-jwt-auth-hook 0.0.1 → 0.0.2-canary.585db17.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/package.json +19 -11
- package/src/jwt-auth-hook.config.spec.ts +0 -2
- package/src/jwt-auth.hook.spec.ts +55 -41
- package/src/jwt-auth.hook.ts +3 -11
- package/src/jwt-auth.service.spec.ts +0 -10
- package/src/jwt-auth.service.ts +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dismissible/nestjs-jwt-auth-hook",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2-canary.585db17.0",
|
|
4
4
|
"description": "JWT authentication hook for Dismissible applications using OIDC well-known discovery",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -12,30 +12,38 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"@nestjs/axios": "^4.0.1",
|
|
16
|
+
"@dismissible/nestjs-dismissible-hooks": "^0.0.2-canary.585db17.0",
|
|
17
|
+
"@dismissible/nestjs-dismissible-request": "^0.0.2-canary.585db17.0",
|
|
18
|
+
"@dismissible/nestjs-logger": "^0.0.2-canary.585db17.0",
|
|
19
|
+
"@dismissible/nestjs-validation": "^0.0.2-canary.585db17.0",
|
|
20
|
+
"jwks-rsa": "^3.2.0",
|
|
21
|
+
"jsonwebtoken": "^9.0.3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/jsonwebtoken": "^9.0.10"
|
|
17
25
|
},
|
|
18
26
|
"peerDependencies": {
|
|
19
|
-
"@nestjs/axios": "^4.0.0",
|
|
20
27
|
"@nestjs/common": "^11.0.0",
|
|
21
28
|
"@nestjs/core": "^11.0.0",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
29
|
+
"class-validator": "^0.14.0",
|
|
30
|
+
"class-transformer": "^0.5.0",
|
|
31
|
+
"rxjs": "^7.8.2"
|
|
24
32
|
},
|
|
25
33
|
"peerDependenciesMeta": {
|
|
26
|
-
"@nestjs/axios": {
|
|
27
|
-
"optional": false
|
|
28
|
-
},
|
|
29
34
|
"@nestjs/common": {
|
|
30
35
|
"optional": false
|
|
31
36
|
},
|
|
32
37
|
"@nestjs/core": {
|
|
33
38
|
"optional": false
|
|
34
39
|
},
|
|
35
|
-
"
|
|
40
|
+
"class-validator": {
|
|
41
|
+
"optional": false
|
|
42
|
+
},
|
|
43
|
+
"class-transformer": {
|
|
36
44
|
"optional": false
|
|
37
45
|
},
|
|
38
|
-
"
|
|
46
|
+
"rxjs": {
|
|
39
47
|
"optional": false
|
|
40
48
|
}
|
|
41
49
|
},
|
|
@@ -56,7 +56,6 @@ describe('JwtAuthHookConfig', () => {
|
|
|
56
56
|
wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
// The transform converts string values: 'true' -> true, anything else -> false
|
|
60
59
|
expect(config.enabled).toBe(false);
|
|
61
60
|
});
|
|
62
61
|
});
|
|
@@ -78,7 +77,6 @@ describe('JwtAuthHookConfig', () => {
|
|
|
78
77
|
});
|
|
79
78
|
|
|
80
79
|
const errors = await validate(config);
|
|
81
|
-
// wellKnownUrl should not be required when enabled is false
|
|
82
80
|
expect(errors.some((e) => e.property === 'wellKnownUrl')).toBe(false);
|
|
83
81
|
});
|
|
84
82
|
|
|
@@ -4,7 +4,32 @@ import { JwtAuthHook } from './jwt-auth.hook';
|
|
|
4
4
|
import { JwtAuthService, IJwtValidationResult } from './jwt-auth.service';
|
|
5
5
|
import { JwtAuthHookConfig } from './jwt-auth-hook.config';
|
|
6
6
|
import { IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
7
|
-
import { IRequestContext } from '@dismissible/nestjs-dismissible';
|
|
7
|
+
import { IRequestContext } from '@dismissible/nestjs-dismissible-request';
|
|
8
|
+
|
|
9
|
+
function createMinimalContext(overrides: Partial<IRequestContext> = {}): IRequestContext {
|
|
10
|
+
return {
|
|
11
|
+
requestId: 'req-123',
|
|
12
|
+
headers: {},
|
|
13
|
+
query: {},
|
|
14
|
+
params: {},
|
|
15
|
+
body: {},
|
|
16
|
+
user: {},
|
|
17
|
+
ip: '127.0.0.1',
|
|
18
|
+
method: 'GET',
|
|
19
|
+
url: '/test',
|
|
20
|
+
protocol: 'http',
|
|
21
|
+
secure: false,
|
|
22
|
+
hostname: 'localhost',
|
|
23
|
+
port: 3000,
|
|
24
|
+
path: '/test',
|
|
25
|
+
search: '',
|
|
26
|
+
searchParams: {},
|
|
27
|
+
origin: 'http://localhost:3000',
|
|
28
|
+
referer: '',
|
|
29
|
+
userAgent: 'test-agent',
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
8
33
|
|
|
9
34
|
describe('JwtAuthHook', () => {
|
|
10
35
|
let hook: JwtAuthHook;
|
|
@@ -44,7 +69,7 @@ describe('JwtAuthHook', () => {
|
|
|
44
69
|
const disabledConfig = { ...mockConfig, enabled: false };
|
|
45
70
|
const disabledHook = new JwtAuthHook(mockJwtAuthService, disabledConfig, mockLogger);
|
|
46
71
|
|
|
47
|
-
const context
|
|
72
|
+
const context = createMinimalContext();
|
|
48
73
|
const result = await disabledHook.onBeforeRequest(testItemId, testUserId, context);
|
|
49
74
|
|
|
50
75
|
expect(result.proceed).toBe(true);
|
|
@@ -55,7 +80,7 @@ describe('JwtAuthHook', () => {
|
|
|
55
80
|
it('should throw UnauthorizedException when no authorization header is present', async () => {
|
|
56
81
|
mockJwtAuthService.extractBearerToken.mockReturnValue(null);
|
|
57
82
|
|
|
58
|
-
const context
|
|
83
|
+
const context = createMinimalContext();
|
|
59
84
|
|
|
60
85
|
await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
|
|
61
86
|
UnauthorizedException,
|
|
@@ -69,10 +94,9 @@ describe('JwtAuthHook', () => {
|
|
|
69
94
|
it('should throw UnauthorizedException when authorization header is malformed', async () => {
|
|
70
95
|
mockJwtAuthService.extractBearerToken.mockReturnValue(null);
|
|
71
96
|
|
|
72
|
-
const context
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
};
|
|
97
|
+
const context = createMinimalContext({
|
|
98
|
+
headers: { authorization: 'Basic abc123' },
|
|
99
|
+
});
|
|
76
100
|
|
|
77
101
|
await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
|
|
78
102
|
UnauthorizedException,
|
|
@@ -90,10 +114,9 @@ describe('JwtAuthHook', () => {
|
|
|
90
114
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
91
115
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
92
116
|
|
|
93
|
-
const context
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
};
|
|
117
|
+
const context = createMinimalContext({
|
|
118
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
119
|
+
});
|
|
97
120
|
|
|
98
121
|
await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
|
|
99
122
|
UnauthorizedException,
|
|
@@ -116,10 +139,9 @@ describe('JwtAuthHook', () => {
|
|
|
116
139
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
117
140
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
118
141
|
|
|
119
|
-
const context
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
};
|
|
142
|
+
const context = createMinimalContext({
|
|
143
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
144
|
+
});
|
|
123
145
|
const result = await hook.onBeforeRequest(testItemId, matchingUserId, context);
|
|
124
146
|
|
|
125
147
|
expect(result.proceed).toBe(true);
|
|
@@ -144,10 +166,9 @@ describe('JwtAuthHook', () => {
|
|
|
144
166
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
145
167
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
146
168
|
|
|
147
|
-
const context
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
};
|
|
169
|
+
const context = createMinimalContext({
|
|
170
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
171
|
+
});
|
|
151
172
|
await hook.onBeforeRequest(testItemId, matchingUserId, context);
|
|
152
173
|
|
|
153
174
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
@@ -172,10 +193,9 @@ describe('JwtAuthHook', () => {
|
|
|
172
193
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
173
194
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
174
195
|
|
|
175
|
-
const context
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
};
|
|
196
|
+
const context = createMinimalContext({
|
|
197
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
198
|
+
});
|
|
179
199
|
|
|
180
200
|
await expect(hook.onBeforeRequest(testItemId, testUserId, context)).rejects.toThrow(
|
|
181
201
|
ForbiddenException,
|
|
@@ -206,10 +226,9 @@ describe('JwtAuthHook', () => {
|
|
|
206
226
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
207
227
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
208
228
|
|
|
209
|
-
const context
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
};
|
|
229
|
+
const context = createMinimalContext({
|
|
230
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
231
|
+
});
|
|
213
232
|
const result = await hook.onBeforeRequest(testItemId, testUserId, context);
|
|
214
233
|
|
|
215
234
|
expect(result.proceed).toBe(true);
|
|
@@ -234,10 +253,9 @@ describe('JwtAuthHook', () => {
|
|
|
234
253
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
235
254
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
236
255
|
|
|
237
|
-
const context
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
};
|
|
256
|
+
const context = createMinimalContext({
|
|
257
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
258
|
+
});
|
|
241
259
|
const result = await disabledVerifyHook.onBeforeRequest(testItemId, testUserId, context);
|
|
242
260
|
|
|
243
261
|
expect(result.proceed).toBe(true);
|
|
@@ -248,16 +266,14 @@ describe('JwtAuthHook', () => {
|
|
|
248
266
|
valid: true,
|
|
249
267
|
payload: {
|
|
250
268
|
iss: 'https://auth.example.com',
|
|
251
|
-
// No sub claim
|
|
252
269
|
},
|
|
253
270
|
};
|
|
254
271
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
255
272
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
256
273
|
|
|
257
|
-
const context
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
};
|
|
274
|
+
const context = createMinimalContext({
|
|
275
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
276
|
+
});
|
|
261
277
|
const result = await hook.onBeforeRequest(testItemId, testUserId, context);
|
|
262
278
|
|
|
263
279
|
expect(result.proceed).toBe(true);
|
|
@@ -266,15 +282,13 @@ describe('JwtAuthHook', () => {
|
|
|
266
282
|
it('should proceed when JWT payload is undefined and verifyUserIdMatch is enabled', async () => {
|
|
267
283
|
const validationResult: IJwtValidationResult = {
|
|
268
284
|
valid: true,
|
|
269
|
-
// No payload
|
|
270
285
|
};
|
|
271
286
|
mockJwtAuthService.extractBearerToken.mockReturnValue('valid-token');
|
|
272
287
|
mockJwtAuthService.validateToken.mockResolvedValue(validationResult);
|
|
273
288
|
|
|
274
|
-
const context
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
};
|
|
289
|
+
const context = createMinimalContext({
|
|
290
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
291
|
+
});
|
|
278
292
|
const result = await hook.onBeforeRequest(testItemId, testUserId, context);
|
|
279
293
|
|
|
280
294
|
expect(result.proceed).toBe(true);
|
package/src/jwt-auth.hook.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { Injectable, Inject, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
IHookResult,
|
|
5
|
-
IRequestContext,
|
|
6
|
-
} from '@dismissible/nestjs-dismissible';
|
|
2
|
+
import { IDismissibleLifecycleHook, IHookResult } from '@dismissible/nestjs-dismissible-hooks';
|
|
3
|
+
import { IRequestContext } from '@dismissible/nestjs-dismissible-request';
|
|
7
4
|
import { DISMISSIBLE_LOGGER, IDismissibleLogger } from '@dismissible/nestjs-logger';
|
|
8
5
|
import { JwtAuthService } from './jwt-auth.service';
|
|
9
6
|
import { JWT_AUTH_HOOK_CONFIG, JwtAuthHookConfig } from './jwt-auth-hook.config';
|
|
@@ -23,7 +20,6 @@ export class JwtAuthHook implements IDismissibleLifecycleHook {
|
|
|
23
20
|
@Inject(DISMISSIBLE_LOGGER)
|
|
24
21
|
private readonly logger: IDismissibleLogger,
|
|
25
22
|
) {
|
|
26
|
-
// Default to -100 for authentication (runs early)
|
|
27
23
|
this.priority = config.priority ?? -100;
|
|
28
24
|
}
|
|
29
25
|
|
|
@@ -40,9 +36,8 @@ export class JwtAuthHook implements IDismissibleLifecycleHook {
|
|
|
40
36
|
return { proceed: true };
|
|
41
37
|
}
|
|
42
38
|
|
|
43
|
-
const authorizationHeader = context?.
|
|
39
|
+
const authorizationHeader = context?.headers['authorization'];
|
|
44
40
|
|
|
45
|
-
// Extract the bearer token
|
|
46
41
|
const token = this.jwtAuthService.extractBearerToken(authorizationHeader);
|
|
47
42
|
|
|
48
43
|
if (!token) {
|
|
@@ -55,7 +50,6 @@ export class JwtAuthHook implements IDismissibleLifecycleHook {
|
|
|
55
50
|
throw new UnauthorizedException('Missing or invalid bearer token');
|
|
56
51
|
}
|
|
57
52
|
|
|
58
|
-
// Validate the token
|
|
59
53
|
const result = await this.jwtAuthService.validateToken(token);
|
|
60
54
|
|
|
61
55
|
if (!result.valid) {
|
|
@@ -69,8 +63,6 @@ export class JwtAuthHook implements IDismissibleLifecycleHook {
|
|
|
69
63
|
throw new UnauthorizedException(result.error);
|
|
70
64
|
}
|
|
71
65
|
|
|
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
66
|
const verifyUserIdMatch = this.config.verifyUserIdMatch ?? true;
|
|
75
67
|
if (verifyUserIdMatch && result.payload?.sub) {
|
|
76
68
|
if (result.payload.sub !== userId) {
|
|
@@ -90,7 +90,6 @@ describe('JwtAuthService', () => {
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
it('should return invalid for malformed token', async () => {
|
|
93
|
-
// Mock HTTP response for well-known config
|
|
94
93
|
const mockResponse: AxiosResponse = {
|
|
95
94
|
data: {
|
|
96
95
|
jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
|
|
@@ -124,7 +123,6 @@ describe('JwtAuthService', () => {
|
|
|
124
123
|
|
|
125
124
|
await service.initializeJwksClient();
|
|
126
125
|
|
|
127
|
-
// Mock jwt.decode to return a string
|
|
128
126
|
const jwt = require('jsonwebtoken');
|
|
129
127
|
jest.spyOn(jwt, 'decode').mockReturnValue('string-token');
|
|
130
128
|
|
|
@@ -148,7 +146,6 @@ describe('JwtAuthService', () => {
|
|
|
148
146
|
|
|
149
147
|
await service.initializeJwksClient();
|
|
150
148
|
|
|
151
|
-
// Mock jwt.decode to return object without kid
|
|
152
149
|
const jwt = require('jsonwebtoken');
|
|
153
150
|
jest.spyOn(jwt, 'decode').mockReturnValue({
|
|
154
151
|
header: {},
|
|
@@ -175,14 +172,12 @@ describe('JwtAuthService', () => {
|
|
|
175
172
|
|
|
176
173
|
await service.initializeJwksClient();
|
|
177
174
|
|
|
178
|
-
// Mock jwt.decode
|
|
179
175
|
const jwt = require('jsonwebtoken');
|
|
180
176
|
jest.spyOn(jwt, 'decode').mockReturnValue({
|
|
181
177
|
header: { kid: 'key-id-123' },
|
|
182
178
|
payload: {},
|
|
183
179
|
});
|
|
184
180
|
|
|
185
|
-
// Mock jwksClient.getSigningKey to throw
|
|
186
181
|
const mockJwksClient = (service as any).jwksClient;
|
|
187
182
|
jest.spyOn(mockJwksClient, 'getSigningKey').mockRejectedValue(new Error('Key not found'));
|
|
188
183
|
|
|
@@ -206,7 +201,6 @@ describe('JwtAuthService', () => {
|
|
|
206
201
|
|
|
207
202
|
await service.initializeJwksClient();
|
|
208
203
|
|
|
209
|
-
// Mock jwt operations
|
|
210
204
|
const jwt = require('jsonwebtoken');
|
|
211
205
|
const mockPayload = {
|
|
212
206
|
sub: 'user-123',
|
|
@@ -283,7 +277,6 @@ describe('JwtAuthService', () => {
|
|
|
283
277
|
const result = await serviceWithoutIssuerAudience.validateToken('valid-token');
|
|
284
278
|
|
|
285
279
|
expect(result.valid).toBe(true);
|
|
286
|
-
// Check the last call (this test's call) doesn't have issuer/audience
|
|
287
280
|
const lastCall = verifySpy.mock.calls[verifySpy.mock.calls.length - 1];
|
|
288
281
|
expect(lastCall[2]).not.toHaveProperty('issuer');
|
|
289
282
|
expect(lastCall[2]).not.toHaveProperty('audience');
|
|
@@ -458,7 +451,6 @@ describe('JwtAuthService', () => {
|
|
|
458
451
|
const mockResponse: AxiosResponse = {
|
|
459
452
|
data: {
|
|
460
453
|
issuer: 'https://auth.example.com',
|
|
461
|
-
// no jwks_uri
|
|
462
454
|
},
|
|
463
455
|
status: 200,
|
|
464
456
|
statusText: 'OK',
|
|
@@ -524,7 +516,6 @@ describe('JwtAuthService', () => {
|
|
|
524
516
|
|
|
525
517
|
await serviceWithCacheDuration.initializeJwksClient();
|
|
526
518
|
|
|
527
|
-
// Verify service initialized successfully with custom cache duration
|
|
528
519
|
expect((serviceWithCacheDuration as any).jwksClient).toBeDefined();
|
|
529
520
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
530
521
|
'JWKS client initialized successfully',
|
|
@@ -545,7 +536,6 @@ describe('JwtAuthService', () => {
|
|
|
545
536
|
mockLogger,
|
|
546
537
|
);
|
|
547
538
|
|
|
548
|
-
// Service should be created without error
|
|
549
539
|
expect(serviceWithDefaults).toBeDefined();
|
|
550
540
|
});
|
|
551
541
|
|
package/src/jwt-auth.service.ts
CHANGED
|
@@ -120,7 +120,6 @@ export class JwtAuthService implements OnModuleInit {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
try {
|
|
123
|
-
// Decode the token header to get the key ID (kid)
|
|
124
123
|
const decoded = jwt.decode(token, { complete: true });
|
|
125
124
|
|
|
126
125
|
if (!decoded || typeof decoded === 'string') {
|
|
@@ -138,7 +137,6 @@ export class JwtAuthService implements OnModuleInit {
|
|
|
138
137
|
};
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
// Get the signing key from JWKS
|
|
142
140
|
let signingKey: SigningKey;
|
|
143
141
|
try {
|
|
144
142
|
signingKey = await this.jwksClient.getSigningKey(kid);
|
|
@@ -151,7 +149,6 @@ export class JwtAuthService implements OnModuleInit {
|
|
|
151
149
|
|
|
152
150
|
const publicKey = signingKey.getPublicKey();
|
|
153
151
|
|
|
154
|
-
// Verify the token
|
|
155
152
|
const verifyOptions: jwt.VerifyOptions = {
|
|
156
153
|
algorithms: (this.config.algorithms as jwt.Algorithm[]) ?? ['RS256'],
|
|
157
154
|
};
|