@workos-inc/authkit-nextjs 2.6.0 → 2.7.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.
Files changed (46) hide show
  1. package/README.md +124 -29
  2. package/dist/esm/components/tokenStore.js +110 -11
  3. package/dist/esm/components/tokenStore.js.map +1 -1
  4. package/dist/esm/components/useAccessToken.js +6 -1
  5. package/dist/esm/components/useAccessToken.js.map +1 -1
  6. package/dist/esm/cookie.js +51 -0
  7. package/dist/esm/cookie.js.map +1 -1
  8. package/dist/esm/middleware.js +2 -2
  9. package/dist/esm/middleware.js.map +1 -1
  10. package/dist/esm/session.js +35 -2
  11. package/dist/esm/session.js.map +1 -1
  12. package/dist/esm/test-helpers.js +57 -0
  13. package/dist/esm/test-helpers.js.map +1 -0
  14. package/dist/esm/types/components/tokenStore.d.ts +7 -2
  15. package/dist/esm/types/cookie.d.ts +1 -0
  16. package/dist/esm/types/interfaces.d.ts +2 -0
  17. package/dist/esm/types/middleware.d.ts +1 -1
  18. package/dist/esm/types/session.d.ts +1 -1
  19. package/dist/esm/types/test-helpers.d.ts +3 -0
  20. package/dist/esm/types/workos.d.ts +1 -1
  21. package/dist/esm/workos.js +1 -1
  22. package/package.json +4 -3
  23. package/src/actions.spec.ts +100 -0
  24. package/src/auth.spec.ts +347 -0
  25. package/src/authkit-callback-route.spec.ts +258 -0
  26. package/src/components/authkit-provider.spec.tsx +471 -0
  27. package/src/components/button.spec.tsx +46 -0
  28. package/src/components/impersonation.spec.tsx +134 -0
  29. package/src/components/min-max-button.spec.tsx +60 -0
  30. package/src/components/tokenStore.spec.ts +816 -0
  31. package/src/components/tokenStore.ts +147 -12
  32. package/src/components/useAccessToken.spec.tsx +731 -0
  33. package/src/components/useAccessToken.ts +6 -1
  34. package/src/components/useTokenClaims.spec.tsx +194 -0
  35. package/src/cookie.spec.ts +276 -0
  36. package/src/cookie.ts +56 -0
  37. package/src/get-authorization-url.spec.ts +60 -0
  38. package/src/interfaces.ts +2 -0
  39. package/src/jwt.spec.ts +159 -0
  40. package/src/middleware.ts +2 -1
  41. package/src/session.spec.ts +1152 -0
  42. package/src/session.ts +41 -1
  43. package/src/test-helpers.ts +70 -0
  44. package/src/utils.spec.ts +142 -0
  45. package/src/workos.spec.ts +67 -0
  46. package/src/workos.ts +1 -1
@@ -0,0 +1,1152 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { cookies, headers } from 'next/headers';
3
+ import { redirect } from 'next/navigation';
4
+ import { generateTestToken } from './test-helpers.js';
5
+ import { withAuth, updateSession, refreshSession, updateSessionMiddleware, getTokenClaims } from './session.js';
6
+ import { getWorkOS } from './workos.js';
7
+ import * as envVariables from './env-variables.js';
8
+
9
+ import { jwtVerify } from 'jose';
10
+ import { sealData } from 'iron-session';
11
+ import { User } from '@workos-inc/node';
12
+
13
+ jest.mock('jose', () => ({
14
+ jwtVerify: jest.fn(),
15
+ createRemoteJWKSet: jest.fn(),
16
+ SignJWT: jest.requireActual('jose').SignJWT,
17
+ decodeJwt: jest.requireActual('jose').decodeJwt,
18
+ }));
19
+
20
+ // logging is disabled by default, flip this to true to still have logs in the console
21
+ const DEBUG = false;
22
+
23
+ const workos = getWorkOS();
24
+
25
+ describe('session.ts', () => {
26
+ const mockSession = {
27
+ accessToken: 'access-token',
28
+ oauthTokens: undefined,
29
+ sessionId: 'session_123',
30
+ organizationId: 'org_123',
31
+ role: 'member',
32
+ permissions: ['posts:create', 'posts:delete'],
33
+ entitlements: ['audit-logs'],
34
+ featureFlags: ['device-authorization-grant'],
35
+ impersonator: undefined,
36
+ user: {
37
+ object: 'user',
38
+ id: 'user_123',
39
+ email: 'test@example.com',
40
+ emailVerified: true,
41
+ profilePictureUrl: null,
42
+ firstName: null,
43
+ lastName: null,
44
+ createdAt: '2024-01-01',
45
+ updatedAt: '2024-01-01',
46
+ } as User,
47
+ };
48
+
49
+ let consoleLogSpy: jest.SpyInstance;
50
+
51
+ beforeEach(async () => {
52
+ // Clear all mocks between tests
53
+ jest.clearAllMocks();
54
+
55
+ // Reset the cookie store
56
+ const nextCookies = await cookies();
57
+ // @ts-expect-error - _reset is part of the mock
58
+ nextCookies._reset();
59
+
60
+ const nextHeaders = await headers();
61
+ // @ts-expect-error - _reset is part of the mock
62
+ nextHeaders._reset();
63
+ nextHeaders.set('x-workos-middleware', 'true');
64
+
65
+ (jwtVerify as jest.Mock).mockReset();
66
+
67
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation((...args) => {
68
+ if (DEBUG) {
69
+ console.info(...args);
70
+ }
71
+ });
72
+ });
73
+
74
+ afterEach(() => {
75
+ consoleLogSpy.mockRestore();
76
+ jest.resetModules();
77
+ });
78
+
79
+ describe('withAuth', () => {
80
+ it('should return user info when authenticated', async () => {
81
+ mockSession.accessToken = await generateTestToken();
82
+
83
+ const nextHeaders = await headers();
84
+
85
+ nextHeaders.set(
86
+ 'x-workos-session',
87
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
88
+ );
89
+
90
+ const result = await withAuth();
91
+ expect(result).toHaveProperty('user');
92
+ expect(result.user).toEqual(mockSession.user);
93
+ });
94
+
95
+ it('should return null when user is not authenticated', async () => {
96
+ const result = await withAuth();
97
+
98
+ expect(result).toEqual({ user: null });
99
+ });
100
+
101
+ it('should redirect when ensureSignedIn is true and user is not authenticated', async () => {
102
+ const nextHeaders = await headers();
103
+ nextHeaders.set('x-url', 'https://example.com/protected');
104
+
105
+ await withAuth({ ensureSignedIn: true });
106
+
107
+ expect(redirect).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it('should throw an error if the route is not covered by the middleware', async () => {
111
+ const nextHeaders = await headers();
112
+ nextHeaders.delete('x-workos-middleware');
113
+ nextHeaders.set('x-url', 'https://example.com/');
114
+
115
+ await expect(async () => {
116
+ await withAuth();
117
+ }).rejects.toThrow(
118
+ "You are calling 'withAuth' on https://example.com/ that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.",
119
+ );
120
+ });
121
+
122
+ it('should throw an error if the route is not covered by the middleware and there is no URL in the headers', async () => {
123
+ const nextHeaders = await headers();
124
+ nextHeaders.delete('x-workos-middleware');
125
+
126
+ await expect(async () => {
127
+ await withAuth({ ensureSignedIn: true });
128
+ }).rejects.toThrow(
129
+ "You are calling 'withAuth' on a route that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.",
130
+ );
131
+ });
132
+
133
+ it('should throw an error if the URL is not found in the headers', async () => {
134
+ const nextHeaders = await headers();
135
+ nextHeaders.delete('x-url');
136
+
137
+ await expect(async () => {
138
+ await withAuth({ ensureSignedIn: true });
139
+ }).rejects.toThrow('No URL found in the headers');
140
+ });
141
+
142
+ it('should include any search parameters in the redirect URL', async () => {
143
+ const nextHeaders = await headers();
144
+ nextHeaders.set('x-url', 'https://example.com/protected?test=123');
145
+
146
+ await withAuth({ ensureSignedIn: true });
147
+
148
+ const pathname = encodeURIComponent(btoa(JSON.stringify({ returnPathname: '/protected?test=123' })));
149
+
150
+ expect(redirect).toHaveBeenCalledWith(expect.stringContaining(pathname));
151
+ });
152
+ });
153
+
154
+ describe('updateSessionMiddleware', () => {
155
+ it('should throw an error if the redirect URI is not set', async () => {
156
+ const originalWorkosRedirectUri = envVariables.WORKOS_REDIRECT_URI;
157
+
158
+ jest.replaceProperty(envVariables, 'WORKOS_REDIRECT_URI', '');
159
+
160
+ await expect(async () => {
161
+ await updateSessionMiddleware(
162
+ new NextRequest(new URL('http://example.com')),
163
+ false,
164
+ {
165
+ enabled: false,
166
+ unauthenticatedPaths: [],
167
+ },
168
+ '',
169
+ [],
170
+ );
171
+ }).rejects.toThrow('You must provide a redirect URI in the AuthKit middleware or in the environment variables.');
172
+
173
+ jest.replaceProperty(envVariables, 'WORKOS_REDIRECT_URI', originalWorkosRedirectUri);
174
+ });
175
+
176
+ it('should throw an error if the cookie password is not set', async () => {
177
+ const originalWorkosCookiePassword = envVariables.WORKOS_COOKIE_PASSWORD;
178
+
179
+ jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', '');
180
+
181
+ await expect(async () => {
182
+ await updateSessionMiddleware(
183
+ new NextRequest(new URL('http://example.com')),
184
+ false,
185
+ {
186
+ enabled: false,
187
+ unauthenticatedPaths: [],
188
+ },
189
+ '',
190
+ [],
191
+ );
192
+ }).rejects.toThrow(
193
+ 'You must provide a valid cookie password that is at least 32 characters in the environment variables.',
194
+ );
195
+
196
+ jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword);
197
+ });
198
+
199
+ it('should throw an error if the cookie password is less than 32 characters', async () => {
200
+ const originalWorkosCookiePassword = envVariables.WORKOS_COOKIE_PASSWORD;
201
+
202
+ jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', 'short');
203
+
204
+ await expect(async () => {
205
+ await updateSessionMiddleware(
206
+ new NextRequest(new URL('http://example.com')),
207
+ false,
208
+ {
209
+ enabled: false,
210
+ unauthenticatedPaths: [],
211
+ },
212
+ '',
213
+ [],
214
+ );
215
+ }).rejects.toThrow(
216
+ 'You must provide a valid cookie password that is at least 32 characters in the environment variables.',
217
+ );
218
+
219
+ jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword);
220
+ });
221
+
222
+ it('should return early if there is no session', async () => {
223
+ const request = new NextRequest(new URL('http://example.com'));
224
+ const result = await updateSessionMiddleware(
225
+ request,
226
+ false,
227
+ {
228
+ enabled: false,
229
+ unauthenticatedPaths: [],
230
+ },
231
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
232
+ [],
233
+ );
234
+
235
+ expect(result).toBeInstanceOf(NextResponse);
236
+ expect(result.status).toBe(200);
237
+ });
238
+
239
+ it('should return 200 if the session is valid', async () => {
240
+ jest.spyOn(console, 'log').mockImplementation(() => {});
241
+
242
+ const nextCookies = await cookies();
243
+ nextCookies.set(
244
+ 'wos-session',
245
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
246
+ );
247
+
248
+ (jwtVerify as jest.Mock).mockImplementation(() => {
249
+ return true;
250
+ });
251
+
252
+ const request = new NextRequest(new URL('http://example.com'));
253
+ const result = await updateSessionMiddleware(
254
+ request,
255
+ true,
256
+ {
257
+ enabled: false,
258
+ unauthenticatedPaths: [],
259
+ },
260
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
261
+ [],
262
+ );
263
+
264
+ expect(result).toBeInstanceOf(NextResponse);
265
+ expect(result.status).toBe(200);
266
+ });
267
+
268
+ it('should attempt to refresh the session when the access token is invalid', async () => {
269
+ mockSession.accessToken = await generateTestToken({}, true);
270
+
271
+ (jwtVerify as jest.Mock).mockImplementation(() => {
272
+ throw new Error('Invalid token');
273
+ });
274
+
275
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
276
+ accessToken: await generateTestToken(),
277
+ refreshToken: 'new-refresh-token',
278
+ user: mockSession.user,
279
+ });
280
+
281
+ const request = new NextRequest(new URL('http://example.com'));
282
+
283
+ request.cookies.set(
284
+ 'wos-session',
285
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
286
+ );
287
+
288
+ const result = await updateSessionMiddleware(
289
+ request,
290
+ true,
291
+ {
292
+ enabled: false,
293
+ unauthenticatedPaths: [],
294
+ },
295
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
296
+ [],
297
+ );
298
+
299
+ expect(result.status).toBe(200);
300
+ expect(console.log).toHaveBeenCalledWith(
301
+ `Session invalid. Refreshing access token that ends in ${mockSession.accessToken.slice(-10)}`,
302
+ );
303
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Session successfully refreshed'));
304
+ });
305
+
306
+ it('should delete the cookie when refreshing fails', async () => {
307
+ jest.spyOn(console, 'log').mockImplementation(() => {});
308
+
309
+ mockSession.accessToken = await generateTestToken({}, true);
310
+
311
+ (jwtVerify as jest.Mock).mockImplementation(() => {
312
+ throw new Error('Invalid token');
313
+ });
314
+
315
+ jest
316
+ .spyOn(workos.userManagement, 'authenticateWithRefreshToken')
317
+ .mockRejectedValue(new Error('Failed to refresh'));
318
+
319
+ const request = new NextRequest(new URL('http://example.com'));
320
+
321
+ request.cookies.set(
322
+ 'wos-session',
323
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
324
+ );
325
+
326
+ const response = await updateSessionMiddleware(
327
+ request,
328
+ true,
329
+ {
330
+ enabled: false,
331
+ unauthenticatedPaths: [],
332
+ },
333
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
334
+ [],
335
+ );
336
+
337
+ expect(response.status).toBe(200);
338
+ expect(response.headers.get('Set-Cookie')).toContain('wos-session=;');
339
+ expect(console.log).toHaveBeenCalledTimes(2);
340
+ expect(console.log).toHaveBeenNthCalledWith(
341
+ 1,
342
+ `Session invalid. Refreshing access token that ends in ${mockSession.accessToken.slice(-10)}`,
343
+ );
344
+ expect(console.log).toHaveBeenNthCalledWith(
345
+ 2,
346
+ 'Failed to refresh. Deleting cookie.',
347
+ new Error('Failed to refresh'),
348
+ );
349
+ });
350
+
351
+ describe('middleware auth', () => {
352
+ it('should redirect unauthenticated users on protected routes', async () => {
353
+ jest.spyOn(console, 'log').mockImplementation(() => {});
354
+
355
+ const request = new NextRequest(new URL('http://example.com/protected'));
356
+ const result = await updateSessionMiddleware(
357
+ request,
358
+ true,
359
+ {
360
+ enabled: true,
361
+ unauthenticatedPaths: [],
362
+ },
363
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
364
+ [],
365
+ );
366
+
367
+ expect(result.status).toBe(307);
368
+ expect(console.log).toHaveBeenCalledWith(
369
+ 'Unauthenticated user on protected route http://example.com/protected, redirecting to AuthKit',
370
+ );
371
+ });
372
+
373
+ it('should use Response if NextResponse.redirect is not available', async () => {
374
+ const originalRedirect = NextResponse.redirect;
375
+ (NextResponse as Partial<typeof NextResponse>).redirect = undefined;
376
+
377
+ const request = new NextRequest(new URL('http://example.com/protected'));
378
+ const result = await updateSessionMiddleware(
379
+ request,
380
+ false,
381
+ {
382
+ enabled: true,
383
+ unauthenticatedPaths: [],
384
+ },
385
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
386
+ [],
387
+ );
388
+
389
+ expect(result).toBeInstanceOf(Response);
390
+
391
+ // Restore the original redirect method
392
+ (NextResponse as Partial<typeof NextResponse>).redirect = originalRedirect;
393
+ });
394
+
395
+ it('should automatically add the redirect URI to unauthenticatedPaths when middleware is enabled', async () => {
396
+ const request = new NextRequest(new URL('http://example.com/protected'));
397
+ const result = await updateSessionMiddleware(
398
+ request,
399
+ false,
400
+ {
401
+ enabled: true,
402
+ unauthenticatedPaths: [],
403
+ },
404
+ 'http://example.com/protected',
405
+ [],
406
+ );
407
+
408
+ expect(result.status).toBe(200);
409
+ });
410
+
411
+ it('should redirect unauthenticated users to sign up page on protected routes included in signUpPaths', async () => {
412
+ const request = new NextRequest(new URL('http://example.com/protected-signup'));
413
+ const result = await updateSessionMiddleware(
414
+ request,
415
+ false,
416
+ {
417
+ enabled: true,
418
+ unauthenticatedPaths: [],
419
+ },
420
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
421
+ ['/protected-signup'],
422
+ );
423
+
424
+ expect(result.status).toBe(307);
425
+ expect(result.headers.get('Location')).toContain('screen_hint=sign-up');
426
+ });
427
+
428
+ it('should set the sign up paths in the headers', async () => {
429
+ const request = new NextRequest(new URL('http://example.com/protected-signup'));
430
+ const result = await updateSessionMiddleware(
431
+ request,
432
+ false,
433
+ {
434
+ enabled: false,
435
+ unauthenticatedPaths: [],
436
+ },
437
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
438
+ ['/protected-signup'],
439
+ );
440
+
441
+ expect(result.headers.get('x-sign-up-paths')).toBe('/protected-signup');
442
+ });
443
+
444
+ it('should allow logged out users on unauthenticated paths', async () => {
445
+ const request = new NextRequest(new URL('http://example.com/unauthenticated'));
446
+ const result = await updateSessionMiddleware(
447
+ request,
448
+ false,
449
+ {
450
+ enabled: true,
451
+ unauthenticatedPaths: ['/unauthenticated'],
452
+ },
453
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
454
+ [],
455
+ );
456
+
457
+ expect(result.status).toBe(200);
458
+ });
459
+
460
+ it('should throw an error if the provided regex is invalid', async () => {
461
+ const request = new NextRequest(new URL('http://example.com/invalid-regex'));
462
+ await expect(async () => {
463
+ await updateSessionMiddleware(
464
+ request,
465
+ false,
466
+ {
467
+ enabled: true,
468
+ unauthenticatedPaths: ['[*'],
469
+ },
470
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
471
+ [],
472
+ );
473
+ }).rejects.toThrow();
474
+ });
475
+
476
+ it('should throw an error if the provided regex is invalid and a non-Error object is thrown', async () => {
477
+ // Reset modules to ensure clean import state
478
+ jest.resetModules();
479
+
480
+ // Import first, then spy
481
+ const pathToRegexp = await import('path-to-regexp');
482
+ const parseSpy = jest.spyOn(pathToRegexp, 'parse').mockImplementation(() => {
483
+ throw 'invalid regex';
484
+ });
485
+
486
+ // Import session after setting up the spy
487
+ const { updateSessionMiddleware } = await import('./session.js');
488
+
489
+ const request = new NextRequest(new URL('http://example.com/invalid-regex'));
490
+
491
+ await expect(async () => {
492
+ await updateSessionMiddleware(
493
+ request,
494
+ false,
495
+ {
496
+ enabled: true,
497
+ unauthenticatedPaths: ['[*'],
498
+ },
499
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
500
+ [],
501
+ );
502
+ }).rejects.toThrow('Error parsing routes for middleware auth. Reason: invalid regex');
503
+
504
+ // Verify the mock was called
505
+ expect(parseSpy).toHaveBeenCalled();
506
+ });
507
+
508
+ it('should default to the WORKOS_REDIRECT_URI environment variable if no redirect URI is provided', async () => {
509
+ const request = new NextRequest(new URL('http://example.com/protected'));
510
+ const result = await updateSessionMiddleware(
511
+ request,
512
+ false,
513
+ {
514
+ enabled: true,
515
+ unauthenticatedPaths: [],
516
+ },
517
+ '',
518
+ [],
519
+ );
520
+
521
+ expect(result.status).toBe(307);
522
+ });
523
+
524
+ it('should delete the cookie and redirect when refreshing fails', async () => {
525
+ jest.spyOn(console, 'log').mockImplementation(() => {});
526
+
527
+ mockSession.accessToken = await generateTestToken({}, true);
528
+
529
+ (jwtVerify as jest.Mock).mockImplementation(() => {
530
+ throw new Error('Invalid token');
531
+ });
532
+
533
+ jest
534
+ .spyOn(workos.userManagement, 'authenticateWithRefreshToken')
535
+ .mockRejectedValue(new Error('Failed to refresh'));
536
+
537
+ const request = new NextRequest(new URL('http://example.com'));
538
+
539
+ request.cookies.set(
540
+ 'wos-session',
541
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
542
+ );
543
+
544
+ const response = await updateSessionMiddleware(
545
+ request,
546
+ true,
547
+ {
548
+ enabled: true,
549
+ unauthenticatedPaths: [],
550
+ },
551
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
552
+ [],
553
+ );
554
+
555
+ expect(response.status).toBe(307);
556
+ expect(response.headers.get('Set-Cookie')).toContain('wos-session=;');
557
+ expect(console.log).toHaveBeenCalledTimes(3);
558
+ expect(console.log).toHaveBeenNthCalledWith(
559
+ 1,
560
+ `Session invalid. Refreshing access token that ends in ${mockSession.accessToken.slice(-10)}`,
561
+ );
562
+ expect(console.log).toHaveBeenNthCalledWith(
563
+ 2,
564
+ 'Failed to refresh. Deleting cookie.',
565
+ new Error('Failed to refresh'),
566
+ );
567
+
568
+ expect(console.log).toHaveBeenNthCalledWith(
569
+ 3,
570
+ 'Unauthenticated user on protected route http://example.com/, redirecting to AuthKit',
571
+ );
572
+ });
573
+
574
+ describe('sign up paths', () => {
575
+ it('should redirect to sign up when unauthenticated user is on a sign up path', async () => {
576
+ const request = new NextRequest(new URL('http://example.com/signup'));
577
+
578
+ const result = await updateSessionMiddleware(
579
+ request,
580
+ false,
581
+ {
582
+ enabled: true,
583
+ unauthenticatedPaths: [],
584
+ },
585
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
586
+ ['/signup'],
587
+ );
588
+
589
+ expect(result.status).toBe(307);
590
+ expect(result.headers.get('Location')).toContain('screen_hint=sign-up');
591
+ });
592
+
593
+ it('should accept a sign up path as a string', async () => {
594
+ const nextHeaders = await headers();
595
+ nextHeaders.set('x-url', 'http://example.com/signup');
596
+ nextHeaders.set('x-sign-up-paths', '/signup');
597
+
598
+ await withAuth({ ensureSignedIn: true });
599
+ expect(redirect).toHaveBeenCalledTimes(1);
600
+ expect(redirect).toHaveBeenCalledWith(expect.stringContaining('screen_hint=sign-up'));
601
+ });
602
+ });
603
+ });
604
+ });
605
+
606
+ describe('updateSession', () => {
607
+ it('should return an authorization url if the session is invalid', async () => {
608
+ const result = await updateSession(new NextRequest(new URL('http://example.com/protected')), {
609
+ debug: true,
610
+ screenHint: 'sign-up',
611
+ });
612
+
613
+ expect(result.authorizationUrl).toBeDefined();
614
+ expect(result.authorizationUrl).toContain('screen_hint=sign-up');
615
+ expect(result.session.user).toBeNull();
616
+ expect(console.log).toHaveBeenCalledWith('No session found from cookie');
617
+ });
618
+
619
+ it('should return a session if the session is valid', async () => {
620
+ const request = new NextRequest(new URL('http://example.com/protected'));
621
+ request.cookies.set(
622
+ 'wos-session',
623
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
624
+ );
625
+
626
+ const result = await updateSession(request);
627
+
628
+ expect(result.session).toBeDefined();
629
+ });
630
+
631
+ it('should attempt to refresh an invalid session', async () => {
632
+ // Setup invalid session
633
+ mockSession.accessToken = await generateTestToken({}, true);
634
+
635
+ // Mock token verification to fail
636
+ (jwtVerify as jest.Mock).mockImplementation(() => {
637
+ throw new Error('Invalid token');
638
+ });
639
+
640
+ // Mock successful refresh
641
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
642
+ accessToken: await generateTestToken(),
643
+ refreshToken: 'new-refresh-token',
644
+ user: mockSession.user,
645
+ });
646
+
647
+ const request = new NextRequest(new URL('http://example.com/protected'));
648
+ request.cookies.set(
649
+ 'wos-session',
650
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
651
+ );
652
+
653
+ const response = await updateSession(request, {
654
+ debug: true,
655
+ });
656
+
657
+ expect(response.session).toBeDefined();
658
+ expect(response.session.user).toBeDefined();
659
+ expect(console.log).toHaveBeenCalledWith(
660
+ expect.stringContaining('Session invalid. Refreshing access token that ends in'),
661
+ );
662
+ });
663
+
664
+ it('should handle refresh failure by returning auth URL', async () => {
665
+ // Setup invalid session
666
+ mockSession.accessToken = await generateTestToken({}, true);
667
+
668
+ // Mock token verification to fail
669
+ (jwtVerify as jest.Mock).mockImplementation(() => {
670
+ throw new Error('Invalid token');
671
+ });
672
+
673
+ // Mock refresh failure
674
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(new Error('Refresh failed'));
675
+
676
+ const request = new NextRequest(new URL('http://example.com/protected'));
677
+ request.cookies.set(
678
+ 'wos-session',
679
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
680
+ );
681
+
682
+ const response = await updateSession(request, {
683
+ debug: true,
684
+ });
685
+
686
+ expect(response.session.user).toBeNull();
687
+ expect(response.authorizationUrl).toBeDefined();
688
+ expect(console.log).toHaveBeenCalledWith('Failed to refresh. Deleting cookie.', expect.any(Error));
689
+ });
690
+
691
+ it('should call onSessionRefreshSuccess when refresh succeeds', async () => {
692
+ // Setup invalid session
693
+ mockSession.accessToken = await generateTestToken({}, true);
694
+
695
+ // Mock token verification to fail
696
+ (jwtVerify as jest.Mock).mockImplementation(() => {
697
+ throw new Error('Invalid token');
698
+ });
699
+
700
+ const newAccessToken = await generateTestToken();
701
+ const mockSuccessCallback = jest.fn();
702
+
703
+ // Mock successful refresh
704
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
705
+ accessToken: newAccessToken,
706
+ refreshToken: 'new-refresh-token',
707
+ user: mockSession.user,
708
+ });
709
+
710
+ const request = new NextRequest(new URL('http://example.com/protected'));
711
+ request.cookies.set(
712
+ 'wos-session',
713
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
714
+ );
715
+
716
+ await updateSession(request, {
717
+ debug: true,
718
+ onSessionRefreshSuccess: mockSuccessCallback,
719
+ });
720
+
721
+ expect(mockSuccessCallback).toHaveBeenCalledTimes(1);
722
+ expect(mockSuccessCallback).toHaveBeenCalledWith({
723
+ accessToken: newAccessToken,
724
+ user: mockSession.user,
725
+ impersonator: undefined,
726
+ organizationId: 'org_123',
727
+ });
728
+ });
729
+
730
+ it('should call onSessionRefreshError when refresh fails', async () => {
731
+ // Setup invalid session
732
+ mockSession.accessToken = await generateTestToken({}, true);
733
+
734
+ // Mock token verification to fail
735
+ (jwtVerify as jest.Mock).mockImplementation(() => {
736
+ throw new Error('Invalid token');
737
+ });
738
+
739
+ const mockError = new Error('Refresh failed');
740
+ const mockErrorCallback = jest.fn();
741
+
742
+ // Mock refresh failure
743
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(mockError);
744
+
745
+ const request = new NextRequest(new URL('http://example.com/protected'));
746
+ request.cookies.set(
747
+ 'wos-session',
748
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
749
+ );
750
+
751
+ await updateSession(request, {
752
+ debug: true,
753
+ onSessionRefreshError: mockErrorCallback,
754
+ });
755
+
756
+ expect(mockErrorCallback).toHaveBeenCalledTimes(1);
757
+ expect(mockErrorCallback).toHaveBeenCalledWith({
758
+ error: mockError,
759
+ request,
760
+ });
761
+ });
762
+ });
763
+
764
+ describe('refreshSession', () => {
765
+ it('should refresh session successfully', async () => {
766
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
767
+ accessToken: await generateTestToken(),
768
+ refreshToken: 'new-refresh-token',
769
+ user: mockSession.user,
770
+ });
771
+
772
+ jest
773
+ .spyOn(workos.userManagement, 'getJwksUrl')
774
+ .mockReturnValue('https://api.workos.com/sso/jwks/client_1234567890');
775
+
776
+ const nextCookies = await cookies();
777
+ nextCookies.set(
778
+ 'wos-session',
779
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
780
+ );
781
+
782
+ const result = await refreshSession({ ensureSignedIn: false });
783
+
784
+ expect(result).toHaveProperty('user');
785
+ expect(result).toHaveProperty('accessToken');
786
+ });
787
+
788
+ it('should return null user when no session exists', async () => {
789
+ const result = await refreshSession({ ensureSignedIn: false });
790
+ expect(result).toEqual({ user: null });
791
+ });
792
+
793
+ it('should redirect to sign in when ensureSignedIn is true and no session exists', async () => {
794
+ const nextHeaders = await headers();
795
+ nextHeaders.set('x-url', 'http://example.com/protected');
796
+
797
+ const response = await refreshSession({ ensureSignedIn: true });
798
+
799
+ expect(response).toEqual({ user: null });
800
+ expect(redirect).toHaveBeenCalledTimes(1);
801
+ });
802
+
803
+ it('should use the organizationId provided in the options', async () => {
804
+ const nextCookies = await cookies();
805
+ nextCookies.set(
806
+ 'wos-session',
807
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
808
+ );
809
+
810
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
811
+ accessToken: await generateTestToken({ org_id: 'org_456' }),
812
+ refreshToken: 'new-refresh-token',
813
+ user: mockSession.user,
814
+ });
815
+
816
+ jest
817
+ .spyOn(workos.userManagement, 'getJwksUrl')
818
+ .mockReturnValue('https://api.workos.com/sso/jwks/client_1234567890');
819
+
820
+ const result = await refreshSession({ organizationId: 'org_456' });
821
+
822
+ expect(result).toHaveProperty('user');
823
+ expect(result.organizationId).toBe('org_456');
824
+ });
825
+
826
+ it('throws if authenticateWithRefreshToken fails with string', async () => {
827
+ const nextCookies = await cookies();
828
+ nextCookies.set(
829
+ 'wos-session',
830
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
831
+ );
832
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue('fail');
833
+ expect(refreshSession({ ensureSignedIn: false })).rejects.toThrow('fail');
834
+ });
835
+
836
+ it('throws if authenticateWithRefreshToken fails with error', async () => {
837
+ const nextCookies = await cookies();
838
+ nextCookies.set(
839
+ 'wos-session',
840
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
841
+ );
842
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(new Error('error'));
843
+ await expect(refreshSession()).rejects.toThrow('error');
844
+ });
845
+ });
846
+
847
+ describe('getTokenClaims', () => {
848
+ beforeEach(async () => {
849
+ const nextCookies = await cookies();
850
+ // @ts-expect-error - _reset is part of the mock
851
+ nextCookies._reset();
852
+ jest.clearAllMocks();
853
+ });
854
+
855
+ it('should return all token claims when accessToken is provided', async () => {
856
+ const tokenPayload = {
857
+ sub: 'user_123',
858
+ org_id: 'org_123',
859
+ role: 'admin',
860
+ permissions: ['read', 'write'],
861
+ entitlements: ['feature_a'],
862
+ feature_flags: ['device-authorization-grant'],
863
+ department: 'engineering',
864
+ level: 5,
865
+ metadata: { theme: 'dark' },
866
+ };
867
+ const token = await generateTestToken(tokenPayload);
868
+
869
+ const result = await getTokenClaims(token);
870
+
871
+ expect(result).toMatchObject(tokenPayload);
872
+ });
873
+
874
+ it('should return empty object when no accessToken is provided and no session exists', async () => {
875
+ const result = await getTokenClaims();
876
+
877
+ expect(result).toEqual({});
878
+ });
879
+
880
+ it('should return all standard claims when token has only standard claims', async () => {
881
+ const tokenPayload = {
882
+ sub: 'user_123',
883
+ org_id: 'org_123',
884
+ role: 'admin',
885
+ permissions: ['read', 'write'],
886
+ entitlements: ['feature_a'],
887
+ feature_flags: ['device-authorization-grant'],
888
+ };
889
+ const token = await generateTestToken(tokenPayload);
890
+
891
+ const result = await getTokenClaims(token);
892
+
893
+ expect(result).toMatchObject(tokenPayload);
894
+ });
895
+
896
+ it('should return all claims including standard JWT claims', async () => {
897
+ const customClaims = {
898
+ customField: 'value',
899
+ anotherCustom: 42,
900
+ };
901
+ const standardClaims = {
902
+ aud: 'audience',
903
+ sub: 'user_123',
904
+ sid: 'session_123',
905
+ org_id: 'org_123',
906
+ role: 'admin',
907
+ permissions: ['read', 'write'],
908
+ entitlements: ['feature_a'],
909
+ feature_flags: ['device-authorization-grant'],
910
+ jti: 'jwt_123',
911
+ };
912
+ const token = await generateTestToken({ ...standardClaims, ...customClaims });
913
+
914
+ const result = await getTokenClaims(token);
915
+
916
+ expect(result).toMatchObject({ ...standardClaims, ...customClaims });
917
+ expect(result).toHaveProperty('exp');
918
+ expect(result).toHaveProperty('iat');
919
+ expect(result).toHaveProperty('iss');
920
+ });
921
+
922
+ it('should handle complex nested claims', async () => {
923
+ const tokenPayload = {
924
+ sub: 'user_123',
925
+ org_id: 'org_123',
926
+ metadata: {
927
+ preferences: { theme: 'dark', language: 'en' },
928
+ settings: ['setting1', 'setting2'],
929
+ },
930
+ tags: ['tag1', 'tag2'],
931
+ permissions_custom: { read: true, write: false },
932
+ };
933
+ const token = await generateTestToken(tokenPayload);
934
+
935
+ const result = await getTokenClaims(token);
936
+
937
+ expect(result).toMatchObject(tokenPayload);
938
+ });
939
+ });
940
+
941
+ describe('eager auth functionality', () => {
942
+ beforeEach(() => {
943
+ jest.clearAllMocks();
944
+ });
945
+
946
+ describe('isInitialDocumentRequest', () => {
947
+ // Since this is not exported, we'll test it indirectly through updateSession
948
+ it('should set JWT cookie on initial page load with eagerAuth enabled', async () => {
949
+ const validAccessToken = await generateTestToken();
950
+ const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
951
+
952
+ const request = new NextRequest(new URL('http://example.com/page'));
953
+ request.headers.set('accept', 'text/html,application/xhtml+xml');
954
+
955
+ request.cookies.set(
956
+ 'wos-session',
957
+ await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
958
+ );
959
+
960
+ const result = await updateSession(request, { eagerAuth: true });
961
+
962
+ // Should have JWT cookie in response headers
963
+ const setCookieHeaders = result.headers.getSetCookie();
964
+ const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
965
+ expect(jwtCookie).toBeDefined();
966
+ expect(jwtCookie).toContain(`workos-access-token=${validAccessToken}`);
967
+ });
968
+
969
+ it('should not set JWT cookie for API requests even with eagerAuth', async () => {
970
+ const validAccessToken = await generateTestToken();
971
+ const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
972
+
973
+ const request = new NextRequest(new URL('http://example.com/api/data'));
974
+ request.headers.set('accept', 'application/json');
975
+
976
+ request.cookies.set(
977
+ 'wos-session',
978
+ await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
979
+ );
980
+
981
+ const result = await updateSession(request, { eagerAuth: true });
982
+
983
+ // Should NOT have JWT cookie in response headers
984
+ const setCookieHeaders = result.headers.getSetCookie();
985
+ const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
986
+ expect(jwtCookie).toBeUndefined();
987
+ });
988
+
989
+ it('should not set JWT cookie for RSC requests', async () => {
990
+ const validAccessToken = await generateTestToken();
991
+ const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
992
+
993
+ const request = new NextRequest(new URL('http://example.com/page'));
994
+ request.headers.set('accept', 'text/html');
995
+ request.headers.set('RSC', '1');
996
+
997
+ request.cookies.set(
998
+ 'wos-session',
999
+ await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
1000
+ );
1001
+
1002
+ const result = await updateSession(request, { eagerAuth: true });
1003
+
1004
+ // Should NOT have JWT cookie for RSC requests
1005
+ const setCookieHeaders = result.headers.getSetCookie();
1006
+ const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
1007
+ expect(jwtCookie).toBeUndefined();
1008
+ });
1009
+
1010
+ it('should not set JWT cookie for prefetch requests', async () => {
1011
+ const validAccessToken = await generateTestToken();
1012
+ const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
1013
+
1014
+ const request = new NextRequest(new URL('http://example.com/page'));
1015
+ request.headers.set('accept', 'text/html');
1016
+ request.headers.set('Purpose', 'prefetch');
1017
+
1018
+ request.cookies.set(
1019
+ 'wos-session',
1020
+ await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
1021
+ );
1022
+
1023
+ const result = await updateSession(request, { eagerAuth: true });
1024
+
1025
+ // Should NOT have JWT cookie for prefetch requests
1026
+ const setCookieHeaders = result.headers.getSetCookie();
1027
+ const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
1028
+ expect(jwtCookie).toBeUndefined();
1029
+ });
1030
+ });
1031
+
1032
+ describe('JWT cookie management during session refresh', () => {
1033
+ it('should set JWT cookie after successful session refresh', async () => {
1034
+ // Setup invalid session that needs refresh
1035
+ mockSession.accessToken = await generateTestToken({}, true);
1036
+
1037
+ (jwtVerify as jest.Mock).mockImplementation(() => {
1038
+ throw new Error('Invalid token');
1039
+ });
1040
+
1041
+ const newAccessToken = await generateTestToken();
1042
+ jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
1043
+ accessToken: newAccessToken,
1044
+ refreshToken: 'new-refresh-token',
1045
+ user: mockSession.user,
1046
+ });
1047
+
1048
+ const request = new NextRequest(new URL('http://example.com/page'));
1049
+ request.headers.set('accept', 'text/html');
1050
+ request.cookies.set(
1051
+ 'wos-session',
1052
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
1053
+ );
1054
+
1055
+ const result = await updateSession(request, { eagerAuth: true });
1056
+
1057
+ // Should set JWT cookie with new token after refresh
1058
+ const setCookieHeaders = result.headers.getSetCookie();
1059
+ const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
1060
+ expect(jwtCookie).toBeDefined();
1061
+ expect(jwtCookie).toContain(`workos-access-token=${newAccessToken}`);
1062
+ });
1063
+
1064
+ it('should delete JWT cookie when session refresh fails', async () => {
1065
+ // Setup invalid session
1066
+ mockSession.accessToken = await generateTestToken({}, true);
1067
+
1068
+ (jwtVerify as jest.Mock).mockImplementation(() => {
1069
+ throw new Error('Invalid token');
1070
+ });
1071
+
1072
+ jest
1073
+ .spyOn(workos.userManagement, 'authenticateWithRefreshToken')
1074
+ .mockRejectedValue(new Error('Refresh failed'));
1075
+
1076
+ const request = new NextRequest(new URL('http://example.com/page'));
1077
+ request.headers.set('accept', 'text/html');
1078
+ request.cookies.set(
1079
+ 'wos-session',
1080
+ await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
1081
+ );
1082
+
1083
+ const result = await updateSession(request, { eagerAuth: true });
1084
+
1085
+ // Should delete JWT cookie on refresh failure
1086
+ const setCookieHeaders = result.headers.getSetCookie();
1087
+ const jwtDeleteCookie = setCookieHeaders.find(
1088
+ (header) => header.includes('workos-access-token=') && header.includes('Max-Age=0'),
1089
+ );
1090
+ expect(jwtDeleteCookie).toBeDefined();
1091
+ });
1092
+ });
1093
+
1094
+ describe('edge cases', () => {
1095
+ it('should handle requests with no accept header', async () => {
1096
+ const validAccessToken = await generateTestToken();
1097
+ const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
1098
+
1099
+ const request = new NextRequest(new URL('http://example.com/page'));
1100
+ // Don't set accept header to test the || '' fallback (line 37)
1101
+
1102
+ request.cookies.set(
1103
+ 'wos-session',
1104
+ await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
1105
+ );
1106
+
1107
+ const result = await updateSession(request, { eagerAuth: true });
1108
+
1109
+ // Without accept header, should not be treated as document request
1110
+ const setCookieHeaders = result.headers.getSetCookie();
1111
+ const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
1112
+ expect(jwtCookie).toBeUndefined();
1113
+ });
1114
+
1115
+ it('should not set duplicate JWT cookie if one already exists with same value', async () => {
1116
+ const validAccessToken = await generateTestToken();
1117
+ const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
1118
+
1119
+ const request = new NextRequest(new URL('http://example.com/page'));
1120
+ request.headers.set('accept', 'text/html');
1121
+
1122
+ // Set existing JWT cookie with same value
1123
+ request.cookies.set('workos-access-token', validAccessToken);
1124
+
1125
+ request.cookies.set(
1126
+ 'wos-session',
1127
+ await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
1128
+ );
1129
+
1130
+ const result = await updateSession(request, { eagerAuth: true });
1131
+
1132
+ // Should NOT set another JWT cookie since one exists with same value (line 192 condition)
1133
+ const setCookieHeaders = result.headers.getSetCookie();
1134
+ const jwtCookies = setCookieHeaders.filter((header) => header.includes('workos-access-token='));
1135
+ expect(jwtCookies).toHaveLength(0); // No new JWT cookie should be set
1136
+ });
1137
+
1138
+ it('should handle saveSession with string URL parameter', async () => {
1139
+ const { saveSession } = await import('./session.js');
1140
+
1141
+ const sessionData = {
1142
+ accessToken: await generateTestToken(),
1143
+ refreshToken: 'test-refresh-token',
1144
+ user: mockSession.user,
1145
+ };
1146
+
1147
+ // Test with string URL (line 545: typeof request === 'string')
1148
+ await expect(saveSession(sessionData, 'https://example.com/callback')).resolves.not.toThrow();
1149
+ });
1150
+ });
1151
+ });
1152
+ });