@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,566 @@
1
+ import { mock } from 'ts-jest-mocker';
2
+ import { of, throwError } from 'rxjs';
3
+ import { HttpService } from '@nestjs/axios';
4
+ import { AxiosResponse } from 'axios';
5
+ import { JwtAuthService } from './jwt-auth.service';
6
+ import { JwtAuthHookConfig } from './jwt-auth-hook.config';
7
+ import { IDismissibleLogger } from '@dismissible/nestjs-logger';
8
+
9
+ describe('JwtAuthService', () => {
10
+ let service: JwtAuthService;
11
+ let mockHttpService: jest.Mocked<HttpService>;
12
+ let mockLogger: jest.Mocked<IDismissibleLogger>;
13
+ let mockConfig: JwtAuthHookConfig;
14
+
15
+ beforeEach(() => {
16
+ mockHttpService = mock(HttpService, { failIfMockNotProvided: false });
17
+ mockLogger = mock<IDismissibleLogger>({ failIfMockNotProvided: false });
18
+ mockConfig = {
19
+ enabled: true,
20
+ wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
21
+ issuer: 'https://auth.example.com',
22
+ audience: 'my-api',
23
+ };
24
+
25
+ service = new JwtAuthService(mockHttpService, mockConfig, mockLogger);
26
+ });
27
+
28
+ describe('extractBearerToken', () => {
29
+ it('should return null when header is undefined', () => {
30
+ const result = service.extractBearerToken(undefined);
31
+ expect(result).toBeNull();
32
+ });
33
+
34
+ it('should return null when header is empty', () => {
35
+ const result = service.extractBearerToken('');
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it('should return null when header does not start with Bearer', () => {
40
+ const result = service.extractBearerToken('Basic abc123');
41
+ expect(result).toBeNull();
42
+ });
43
+
44
+ it('should return null when Bearer has no token', () => {
45
+ const result = service.extractBearerToken('Bearer');
46
+ expect(result).toBeNull();
47
+ });
48
+
49
+ it('should return null when header has too many parts', () => {
50
+ const result = service.extractBearerToken('Bearer token extra');
51
+ expect(result).toBeNull();
52
+ });
53
+
54
+ it('should extract token from valid Bearer header', () => {
55
+ const result = service.extractBearerToken('Bearer eyJhbGciOiJSUzI1NiJ9.test.sig');
56
+ expect(result).toBe('eyJhbGciOiJSUzI1NiJ9.test.sig');
57
+ });
58
+
59
+ it('should be case-insensitive for Bearer prefix', () => {
60
+ const result = service.extractBearerToken('bearer eyJhbGciOiJSUzI1NiJ9.test.sig');
61
+ expect(result).toBe('eyJhbGciOiJSUzI1NiJ9.test.sig');
62
+ });
63
+
64
+ it('should handle BEARER in uppercase', () => {
65
+ const result = service.extractBearerToken('BEARER eyJhbGciOiJSUzI1NiJ9.test.sig');
66
+ expect(result).toBe('eyJhbGciOiJSUzI1NiJ9.test.sig');
67
+ });
68
+ });
69
+
70
+ describe('onModuleInit', () => {
71
+ it('should not initialize JWKS client when disabled', async () => {
72
+ const disabledConfig: JwtAuthHookConfig = {
73
+ ...mockConfig,
74
+ enabled: false,
75
+ };
76
+ const disabledService = new JwtAuthService(mockHttpService, disabledConfig, mockLogger);
77
+
78
+ await disabledService.onModuleInit();
79
+
80
+ expect(mockHttpService.get).not.toHaveBeenCalled();
81
+ });
82
+ });
83
+
84
+ describe('validateToken', () => {
85
+ it('should return invalid when JWKS client is not initialized', async () => {
86
+ const result = await service.validateToken('some-token');
87
+
88
+ expect(result.valid).toBe(false);
89
+ expect(result.error).toBe('JWKS client not initialized');
90
+ });
91
+
92
+ it('should return invalid for malformed token', async () => {
93
+ // Mock HTTP response for well-known config
94
+ const mockResponse: AxiosResponse = {
95
+ data: {
96
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
97
+ },
98
+ status: 200,
99
+ statusText: 'OK',
100
+ headers: {},
101
+ config: { headers: {} } as AxiosResponse['config'],
102
+ };
103
+ mockHttpService.get.mockReturnValue(of(mockResponse));
104
+
105
+ await service.initializeJwksClient();
106
+
107
+ const result = await service.validateToken('not-a-valid-jwt');
108
+
109
+ expect(result.valid).toBe(false);
110
+ expect(result.error).toBe('Invalid token format');
111
+ });
112
+
113
+ it('should return invalid when token is a string (not object)', async () => {
114
+ const mockResponse: AxiosResponse = {
115
+ data: {
116
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
117
+ },
118
+ status: 200,
119
+ statusText: 'OK',
120
+ headers: {},
121
+ config: { headers: {} } as AxiosResponse['config'],
122
+ };
123
+ mockHttpService.get.mockReturnValue(of(mockResponse));
124
+
125
+ await service.initializeJwksClient();
126
+
127
+ // Mock jwt.decode to return a string
128
+ const jwt = require('jsonwebtoken');
129
+ jest.spyOn(jwt, 'decode').mockReturnValue('string-token');
130
+
131
+ const result = await service.validateToken('some-token');
132
+
133
+ expect(result.valid).toBe(false);
134
+ expect(result.error).toBe('Invalid token format');
135
+ });
136
+
137
+ it('should return invalid when token is missing kid', async () => {
138
+ const mockResponse: AxiosResponse = {
139
+ data: {
140
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
141
+ },
142
+ status: 200,
143
+ statusText: 'OK',
144
+ headers: {},
145
+ config: { headers: {} } as AxiosResponse['config'],
146
+ };
147
+ mockHttpService.get.mockReturnValue(of(mockResponse));
148
+
149
+ await service.initializeJwksClient();
150
+
151
+ // Mock jwt.decode to return object without kid
152
+ const jwt = require('jsonwebtoken');
153
+ jest.spyOn(jwt, 'decode').mockReturnValue({
154
+ header: {},
155
+ payload: {},
156
+ });
157
+
158
+ const result = await service.validateToken('some-token');
159
+
160
+ expect(result.valid).toBe(false);
161
+ expect(result.error).toBe('Token missing key ID (kid)');
162
+ });
163
+
164
+ it('should return invalid when signing key cannot be found', async () => {
165
+ const mockResponse: AxiosResponse = {
166
+ data: {
167
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
168
+ },
169
+ status: 200,
170
+ statusText: 'OK',
171
+ headers: {},
172
+ config: { headers: {} } as AxiosResponse['config'],
173
+ };
174
+ mockHttpService.get.mockReturnValue(of(mockResponse));
175
+
176
+ await service.initializeJwksClient();
177
+
178
+ // Mock jwt.decode
179
+ const jwt = require('jsonwebtoken');
180
+ jest.spyOn(jwt, 'decode').mockReturnValue({
181
+ header: { kid: 'key-id-123' },
182
+ payload: {},
183
+ });
184
+
185
+ // Mock jwksClient.getSigningKey to throw
186
+ const mockJwksClient = (service as any).jwksClient;
187
+ jest.spyOn(mockJwksClient, 'getSigningKey').mockRejectedValue(new Error('Key not found'));
188
+
189
+ const result = await service.validateToken('some-token');
190
+
191
+ expect(result.valid).toBe(false);
192
+ expect(result.error).toBe('Unable to find signing key');
193
+ });
194
+
195
+ it('should successfully validate token with issuer and audience', async () => {
196
+ const mockResponse: AxiosResponse = {
197
+ data: {
198
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
199
+ },
200
+ status: 200,
201
+ statusText: 'OK',
202
+ headers: {},
203
+ config: { headers: {} } as AxiosResponse['config'],
204
+ };
205
+ mockHttpService.get.mockReturnValue(of(mockResponse));
206
+
207
+ await service.initializeJwksClient();
208
+
209
+ // Mock jwt operations
210
+ const jwt = require('jsonwebtoken');
211
+ const mockPayload = {
212
+ sub: 'user-123',
213
+ iss: 'https://auth.example.com',
214
+ aud: 'my-api',
215
+ };
216
+
217
+ jest.spyOn(jwt, 'decode').mockReturnValue({
218
+ header: { kid: 'key-id-123' },
219
+ payload: mockPayload,
220
+ });
221
+
222
+ const mockSigningKey = {
223
+ getPublicKey: jest.fn().mockReturnValue('public-key'),
224
+ };
225
+ const mockJwksClient = (service as any).jwksClient;
226
+ jest.spyOn(mockJwksClient, 'getSigningKey').mockResolvedValue(mockSigningKey);
227
+ jest.spyOn(jwt, 'verify').mockReturnValue(mockPayload);
228
+
229
+ const result = await service.validateToken('valid-token');
230
+
231
+ expect(result.valid).toBe(true);
232
+ expect(result.payload).toEqual(mockPayload);
233
+ expect(jwt.verify).toHaveBeenCalledWith(
234
+ 'valid-token',
235
+ 'public-key',
236
+ expect.objectContaining({
237
+ algorithms: ['RS256'],
238
+ issuer: 'https://auth.example.com',
239
+ audience: 'my-api',
240
+ }),
241
+ );
242
+ });
243
+
244
+ it('should successfully validate token without issuer and audience', async () => {
245
+ const configWithoutIssuerAudience: JwtAuthHookConfig = {
246
+ enabled: true,
247
+ wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
248
+ };
249
+ const serviceWithoutIssuerAudience = new JwtAuthService(
250
+ mockHttpService,
251
+ configWithoutIssuerAudience,
252
+ mockLogger,
253
+ );
254
+
255
+ const mockResponse: AxiosResponse = {
256
+ data: {
257
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
258
+ },
259
+ status: 200,
260
+ statusText: 'OK',
261
+ headers: {},
262
+ config: { headers: {} } as AxiosResponse['config'],
263
+ };
264
+ mockHttpService.get.mockReturnValue(of(mockResponse));
265
+
266
+ await serviceWithoutIssuerAudience.initializeJwksClient();
267
+
268
+ const jwt = require('jsonwebtoken');
269
+ const mockPayload = { sub: 'user-123' };
270
+
271
+ jest.spyOn(jwt, 'decode').mockReturnValue({
272
+ header: { kid: 'key-id-123' },
273
+ payload: mockPayload,
274
+ });
275
+
276
+ const mockSigningKey = {
277
+ getPublicKey: jest.fn().mockReturnValue('public-key'),
278
+ };
279
+ const mockJwksClient = (serviceWithoutIssuerAudience as any).jwksClient;
280
+ jest.spyOn(mockJwksClient, 'getSigningKey').mockResolvedValue(mockSigningKey);
281
+ const verifySpy = jest.spyOn(jwt, 'verify').mockReturnValue(mockPayload);
282
+
283
+ const result = await serviceWithoutIssuerAudience.validateToken('valid-token');
284
+
285
+ expect(result.valid).toBe(true);
286
+ // Check the last call (this test's call) doesn't have issuer/audience
287
+ const lastCall = verifySpy.mock.calls[verifySpy.mock.calls.length - 1];
288
+ expect(lastCall[2]).not.toHaveProperty('issuer');
289
+ expect(lastCall[2]).not.toHaveProperty('audience');
290
+ expect(lastCall[2]).toHaveProperty('algorithms', ['RS256']);
291
+ });
292
+
293
+ it('should return invalid when token verification fails', async () => {
294
+ const mockResponse: AxiosResponse = {
295
+ data: {
296
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
297
+ },
298
+ status: 200,
299
+ statusText: 'OK',
300
+ headers: {},
301
+ config: { headers: {} } as AxiosResponse['config'],
302
+ };
303
+ mockHttpService.get.mockReturnValue(of(mockResponse));
304
+
305
+ await service.initializeJwksClient();
306
+
307
+ const jwt = require('jsonwebtoken');
308
+ jest.spyOn(jwt, 'decode').mockReturnValue({
309
+ header: { kid: 'key-id-123' },
310
+ payload: {},
311
+ });
312
+
313
+ const mockSigningKey = {
314
+ getPublicKey: jest.fn().mockReturnValue('public-key'),
315
+ };
316
+ const mockJwksClient = (service as any).jwksClient;
317
+ jest.spyOn(mockJwksClient, 'getSigningKey').mockResolvedValue(mockSigningKey);
318
+ jest.spyOn(jwt, 'verify').mockImplementation(() => {
319
+ throw new Error('Token expired');
320
+ });
321
+
322
+ const result = await service.validateToken('expired-token');
323
+
324
+ expect(result.valid).toBe(false);
325
+ expect(result.error).toBe('Token expired');
326
+ expect(mockLogger.debug).toHaveBeenCalledWith(
327
+ 'Token validation failed',
328
+ expect.objectContaining({
329
+ error: 'Token expired',
330
+ }),
331
+ );
332
+ });
333
+
334
+ it('should handle non-Error objects in verification catch block', async () => {
335
+ const mockResponse: AxiosResponse = {
336
+ data: {
337
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
338
+ },
339
+ status: 200,
340
+ statusText: 'OK',
341
+ headers: {},
342
+ config: { headers: {} } as AxiosResponse['config'],
343
+ };
344
+ mockHttpService.get.mockReturnValue(of(mockResponse));
345
+
346
+ await service.initializeJwksClient();
347
+
348
+ const jwt = require('jsonwebtoken');
349
+ jest.spyOn(jwt, 'decode').mockReturnValue({
350
+ header: { kid: 'key-id-123' },
351
+ payload: {},
352
+ });
353
+
354
+ const mockSigningKey = {
355
+ getPublicKey: jest.fn().mockReturnValue('public-key'),
356
+ };
357
+ const mockJwksClient = (service as any).jwksClient;
358
+ jest.spyOn(mockJwksClient, 'getSigningKey').mockResolvedValue(mockSigningKey);
359
+ jest.spyOn(jwt, 'verify').mockImplementation(() => {
360
+ throw 'String error';
361
+ });
362
+
363
+ const result = await service.validateToken('invalid-token');
364
+
365
+ expect(result.valid).toBe(false);
366
+ expect(result.error).toBe('String error');
367
+ });
368
+
369
+ it('should use custom algorithms from config', async () => {
370
+ const configWithAlgorithms: JwtAuthHookConfig = {
371
+ ...mockConfig,
372
+ algorithms: ['RS256', 'RS384'],
373
+ };
374
+ const serviceWithAlgorithms = new JwtAuthService(
375
+ mockHttpService,
376
+ configWithAlgorithms,
377
+ mockLogger,
378
+ );
379
+
380
+ const mockResponse: AxiosResponse = {
381
+ data: {
382
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
383
+ },
384
+ status: 200,
385
+ statusText: 'OK',
386
+ headers: {},
387
+ config: { headers: {} } as AxiosResponse['config'],
388
+ };
389
+ mockHttpService.get.mockReturnValue(of(mockResponse));
390
+
391
+ await serviceWithAlgorithms.initializeJwksClient();
392
+
393
+ const jwt = require('jsonwebtoken');
394
+ const mockPayload = { sub: 'user-123' };
395
+
396
+ jest.spyOn(jwt, 'decode').mockReturnValue({
397
+ header: { kid: 'key-id-123' },
398
+ payload: mockPayload,
399
+ });
400
+
401
+ const mockSigningKey = {
402
+ getPublicKey: jest.fn().mockReturnValue('public-key'),
403
+ };
404
+ const mockJwksClient = (serviceWithAlgorithms as any).jwksClient;
405
+ jest.spyOn(mockJwksClient, 'getSigningKey').mockResolvedValue(mockSigningKey);
406
+ jest.spyOn(jwt, 'verify').mockReturnValue(mockPayload);
407
+
408
+ await serviceWithAlgorithms.validateToken('valid-token');
409
+
410
+ expect(jwt.verify).toHaveBeenCalledWith(
411
+ 'valid-token',
412
+ 'public-key',
413
+ expect.objectContaining({
414
+ algorithms: ['RS256', 'RS384'],
415
+ }),
416
+ );
417
+ });
418
+ });
419
+
420
+ describe('initializeJwksClient', () => {
421
+ it('should fetch well-known configuration and initialize JWKS client', async () => {
422
+ const mockResponse: AxiosResponse = {
423
+ data: {
424
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
425
+ issuer: 'https://auth.example.com',
426
+ },
427
+ status: 200,
428
+ statusText: 'OK',
429
+ headers: {},
430
+ config: { headers: {} } as AxiosResponse['config'],
431
+ };
432
+ mockHttpService.get.mockReturnValue(of(mockResponse));
433
+
434
+ await service.initializeJwksClient();
435
+
436
+ expect(mockHttpService.get).toHaveBeenCalledWith(
437
+ 'https://auth.example.com/.well-known/openid-configuration',
438
+ expect.objectContaining({
439
+ timeout: 30000,
440
+ }),
441
+ );
442
+ expect(mockLogger.info).toHaveBeenCalledWith(
443
+ 'JWKS client initialized successfully',
444
+ expect.objectContaining({
445
+ jwksUri: 'https://auth.example.com/.well-known/jwks.json',
446
+ }),
447
+ );
448
+ });
449
+
450
+ it('should throw error when well-known fetch fails', async () => {
451
+ mockHttpService.get.mockReturnValue(throwError(() => new Error('Network error')));
452
+
453
+ await expect(service.initializeJwksClient()).rejects.toThrow('Network error');
454
+ expect(mockLogger.error).toHaveBeenCalled();
455
+ });
456
+
457
+ it('should throw error when jwks_uri is missing', async () => {
458
+ const mockResponse: AxiosResponse = {
459
+ data: {
460
+ issuer: 'https://auth.example.com',
461
+ // no jwks_uri
462
+ },
463
+ status: 200,
464
+ statusText: 'OK',
465
+ headers: {},
466
+ config: { headers: {} } as AxiosResponse['config'],
467
+ };
468
+ mockHttpService.get.mockReturnValue(of(mockResponse));
469
+
470
+ await expect(service.initializeJwksClient()).rejects.toThrow(
471
+ 'No jwks_uri found in OpenID configuration',
472
+ );
473
+ });
474
+
475
+ it('should use custom timeout from config', async () => {
476
+ const configWithTimeout: JwtAuthHookConfig = {
477
+ ...mockConfig,
478
+ requestTimeout: 5000,
479
+ };
480
+ const serviceWithTimeout = new JwtAuthService(mockHttpService, configWithTimeout, mockLogger);
481
+
482
+ const mockResponse: AxiosResponse = {
483
+ data: {
484
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
485
+ },
486
+ status: 200,
487
+ statusText: 'OK',
488
+ headers: {},
489
+ config: { headers: {} } as AxiosResponse['config'],
490
+ };
491
+ mockHttpService.get.mockReturnValue(of(mockResponse));
492
+
493
+ await serviceWithTimeout.initializeJwksClient();
494
+
495
+ expect(mockHttpService.get).toHaveBeenCalledWith(
496
+ mockConfig.wellKnownUrl,
497
+ expect.objectContaining({
498
+ timeout: 5000,
499
+ }),
500
+ );
501
+ });
502
+
503
+ it('should use custom jwksCacheDuration from config', async () => {
504
+ const configWithCacheDuration: JwtAuthHookConfig = {
505
+ ...mockConfig,
506
+ jwksCacheDuration: 300000, // 5 minutes
507
+ };
508
+ const serviceWithCacheDuration = new JwtAuthService(
509
+ mockHttpService,
510
+ configWithCacheDuration,
511
+ mockLogger,
512
+ );
513
+
514
+ const mockResponse: AxiosResponse = {
515
+ data: {
516
+ jwks_uri: 'https://auth.example.com/.well-known/jwks.json',
517
+ },
518
+ status: 200,
519
+ statusText: 'OK',
520
+ headers: {},
521
+ config: { headers: {} } as AxiosResponse['config'],
522
+ };
523
+ mockHttpService.get.mockReturnValue(of(mockResponse));
524
+
525
+ await serviceWithCacheDuration.initializeJwksClient();
526
+
527
+ // Verify service initialized successfully with custom cache duration
528
+ expect((serviceWithCacheDuration as any).jwksClient).toBeDefined();
529
+ expect(mockLogger.info).toHaveBeenCalledWith(
530
+ 'JWKS client initialized successfully',
531
+ expect.any(Object),
532
+ );
533
+ });
534
+ });
535
+
536
+ describe('configuration options', () => {
537
+ it('should use default algorithms when not specified', () => {
538
+ const configWithoutAlgorithms: JwtAuthHookConfig = {
539
+ enabled: true,
540
+ wellKnownUrl: 'https://auth.example.com/.well-known/openid-configuration',
541
+ };
542
+ const serviceWithDefaults = new JwtAuthService(
543
+ mockHttpService,
544
+ configWithoutAlgorithms,
545
+ mockLogger,
546
+ );
547
+
548
+ // Service should be created without error
549
+ expect(serviceWithDefaults).toBeDefined();
550
+ });
551
+
552
+ it('should accept custom algorithms', () => {
553
+ const configWithAlgorithms: JwtAuthHookConfig = {
554
+ ...mockConfig,
555
+ algorithms: ['RS256', 'RS384'],
556
+ };
557
+ const serviceWithAlgorithms = new JwtAuthService(
558
+ mockHttpService,
559
+ configWithAlgorithms,
560
+ mockLogger,
561
+ );
562
+
563
+ expect(serviceWithAlgorithms).toBeDefined();
564
+ });
565
+ });
566
+ });