@workos-inc/authkit-nextjs 2.17.0 → 3.0.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 (84) hide show
  1. package/README.md +40 -11
  2. package/dist/esm/actions.js +35 -4
  3. package/dist/esm/actions.js.map +1 -1
  4. package/dist/esm/auth.js +13 -22
  5. package/dist/esm/auth.js.map +1 -1
  6. package/dist/esm/authkit-callback-route.js +71 -95
  7. package/dist/esm/authkit-callback-route.js.map +1 -1
  8. package/dist/esm/components/authkit-provider.js +31 -13
  9. package/dist/esm/components/authkit-provider.js.map +1 -1
  10. package/dist/esm/components/impersonation.js +9 -9
  11. package/dist/esm/components/impersonation.js.map +1 -1
  12. package/dist/esm/components/min-max-button.js +1 -1
  13. package/dist/esm/components/min-max-button.js.map +1 -1
  14. package/dist/esm/components/tokenStore.js +28 -19
  15. package/dist/esm/components/tokenStore.js.map +1 -1
  16. package/dist/esm/components/useAccessToken.js +1 -1
  17. package/dist/esm/components/useAccessToken.js.map +1 -1
  18. package/dist/esm/components/useTokenClaims.js +1 -1
  19. package/dist/esm/components/useTokenClaims.js.map +1 -1
  20. package/dist/esm/cookie.js +16 -5
  21. package/dist/esm/cookie.js.map +1 -1
  22. package/dist/esm/env-variables.js +5 -7
  23. package/dist/esm/env-variables.js.map +1 -1
  24. package/dist/esm/errors.js +7 -4
  25. package/dist/esm/errors.js.map +1 -1
  26. package/dist/esm/get-authorization-url.js +23 -27
  27. package/dist/esm/get-authorization-url.js.map +1 -1
  28. package/dist/esm/index.js +3 -3
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/interfaces.js +7 -1
  31. package/dist/esm/interfaces.js.map +1 -1
  32. package/dist/esm/middleware-helpers.js +8 -5
  33. package/dist/esm/middleware-helpers.js.map +1 -1
  34. package/dist/esm/middleware.js +3 -1
  35. package/dist/esm/middleware.js.map +1 -1
  36. package/dist/esm/pkce.js +17 -22
  37. package/dist/esm/pkce.js.map +1 -1
  38. package/dist/esm/session.js +19 -23
  39. package/dist/esm/session.js.map +1 -1
  40. package/dist/esm/types/actions.d.ts +34 -5
  41. package/dist/esm/types/auth.d.ts +6 -16
  42. package/dist/esm/types/cookie.d.ts +8 -0
  43. package/dist/esm/types/env-variables.d.ts +1 -2
  44. package/dist/esm/types/get-authorization-url.d.ts +1 -1
  45. package/dist/esm/types/index.d.ts +3 -3
  46. package/dist/esm/types/interfaces.d.ts +9 -1
  47. package/dist/esm/types/jwt.d.ts +9 -9
  48. package/dist/esm/types/middleware-helpers.d.ts +3 -1
  49. package/dist/esm/types/middleware.d.ts +3 -1
  50. package/dist/esm/types/pkce.d.ts +6 -5
  51. package/dist/esm/utils.js +2 -2
  52. package/dist/esm/utils.js.map +1 -1
  53. package/dist/esm/validate-api-key.js +1 -2
  54. package/dist/esm/validate-api-key.js.map +1 -1
  55. package/package.json +12 -13
  56. package/src/actions.spec.ts +81 -6
  57. package/src/actions.ts +44 -5
  58. package/src/auth.spec.ts +3 -2
  59. package/src/auth.ts +12 -43
  60. package/src/authkit-callback-route.spec.ts +210 -60
  61. package/src/authkit-callback-route.ts +94 -107
  62. package/src/components/authkit-provider.spec.tsx +89 -6
  63. package/src/components/authkit-provider.tsx +20 -1
  64. package/src/components/impersonation.spec.tsx +1 -0
  65. package/src/components/impersonation.tsx +29 -24
  66. package/src/components/tokenStore.spec.ts +35 -20
  67. package/src/components/tokenStore.ts +11 -3
  68. package/src/components/useAccessToken.spec.tsx +15 -12
  69. package/src/components/useTokenClaims.spec.tsx +1 -0
  70. package/src/cookie.ts +29 -0
  71. package/src/env-variables.ts +0 -2
  72. package/src/get-authorization-url.spec.ts +18 -40
  73. package/src/get-authorization-url.ts +34 -40
  74. package/src/index.ts +3 -1
  75. package/src/interfaces.ts +11 -1
  76. package/src/jwt.ts +9 -9
  77. package/src/middleware-helpers.spec.ts +7 -0
  78. package/src/middleware-helpers.ts +7 -3
  79. package/src/middleware.spec.ts +25 -0
  80. package/src/middleware.ts +4 -1
  81. package/src/pkce.spec.ts +125 -0
  82. package/src/pkce.ts +19 -19
  83. package/src/session.spec.ts +18 -22
  84. package/src/session.ts +10 -12
@@ -1,3 +1,4 @@
1
+ import type { Mock } from 'vitest';
1
2
  import { getWorkOS } from './workos.js';
2
3
  import { handleAuth } from './authkit-callback-route.js';
3
4
  import { getSessionFromCookie, saveSession } from './session.js';
@@ -6,6 +7,7 @@ import { sealData } from 'iron-session';
6
7
 
7
8
  // Mocked in vitest.setup.ts
8
9
  import { cookies, headers } from 'next/headers';
10
+ import { State } from './interfaces.js';
9
11
 
10
12
  // Mock dependencies
11
13
  const { fakeWorkosInstance } = vi.hoisted(() => ({
@@ -52,6 +54,12 @@ describe('authkit-callback-route', () => {
52
54
  },
53
55
  };
54
56
 
57
+ async function setAuthCookie(req: NextRequest, state: State): Promise<string> {
58
+ const sealedState = await sealData(state, { password: process.env.WORKOS_COOKIE_PASSWORD! });
59
+ req.cookies.set('wos-auth-verifier', sealedState);
60
+ return sealedState;
61
+ }
62
+
55
63
  describe('handleAuth', () => {
56
64
  let request: NextRequest;
57
65
 
@@ -80,8 +88,10 @@ describe('authkit-callback-route', () => {
80
88
  it('should handle successful authentication', async () => {
81
89
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
82
90
 
83
- // Set up request with code
91
+ // Set up request with code & state
92
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
84
93
  request.nextUrl.searchParams.set('code', 'test-code');
94
+ request.nextUrl.searchParams.set('state', sealedState);
85
95
 
86
96
  const handler = handleAuth();
87
97
  const response = await handler(request);
@@ -89,15 +99,17 @@ describe('authkit-callback-route', () => {
89
99
  expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({
90
100
  clientId: process.env.WORKOS_CLIENT_ID,
91
101
  code: 'test-code',
102
+ codeVerifier: 'test-verifier',
92
103
  });
93
104
  expect(response).toBeInstanceOf(NextResponse);
94
105
  });
95
106
 
96
107
  it('should handle authentication failure', async () => {
97
- // Mock authentication failure
98
108
  (workos.userManagement.authenticateWithCode as Mock).mockRejectedValue(new Error('Auth failed'));
99
109
 
110
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
100
111
  request.nextUrl.searchParams.set('code', 'invalid-code');
112
+ request.nextUrl.searchParams.set('state', sealedState);
101
113
 
102
114
  const handler = handleAuth();
103
115
  const response = await handler(request);
@@ -108,10 +120,11 @@ describe('authkit-callback-route', () => {
108
120
  });
109
121
 
110
122
  it('should handle authentication failure if a non-Error object is thrown', async () => {
111
- // Mock authentication failure
112
123
  vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
113
124
 
125
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
114
126
  request.nextUrl.searchParams.set('code', 'invalid-code');
127
+ request.nextUrl.searchParams.set('state', sealedState);
115
128
 
116
129
  const handler = handleAuth();
117
130
  const response = await handler(request);
@@ -122,9 +135,11 @@ describe('authkit-callback-route', () => {
122
135
  });
123
136
 
124
137
  it('should handle authentication failure with custom onError handler', async () => {
125
- // Mock authentication failure
126
138
  vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
139
+
140
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
127
141
  request.nextUrl.searchParams.set('code', 'invalid-code');
142
+ request.nextUrl.searchParams.set('state', sealedState);
128
143
 
129
144
  const handler = handleAuth({
130
145
  onError: () => {
@@ -153,7 +168,9 @@ describe('authkit-callback-route', () => {
153
168
  it('should respect custom returnPathname', async () => {
154
169
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
155
170
 
171
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
156
172
  request.nextUrl.searchParams.set('code', 'test-code');
173
+ request.nextUrl.searchParams.set('state', sealedState);
157
174
 
158
175
  const handler = handleAuth({ returnPathname: '/dashboard' });
159
176
  const response = await handler(request);
@@ -164,9 +181,13 @@ describe('authkit-callback-route', () => {
164
181
  it('should handle state parameter with returnPathname', async () => {
165
182
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
166
183
 
167
- const state = btoa(JSON.stringify({ returnPathname: '/custom-path' }));
184
+ const sealedState = await setAuthCookie(request, {
185
+ nonce: 'foo',
186
+ codeVerifier: 'test-verifier',
187
+ returnPathname: '/custom-path',
188
+ });
168
189
  request.nextUrl.searchParams.set('code', 'test-code');
169
- request.nextUrl.searchParams.set('state', state);
190
+ request.nextUrl.searchParams.set('state', sealedState);
170
191
 
171
192
  const handler = handleAuth();
172
193
  const response = await handler(request);
@@ -177,9 +198,13 @@ describe('authkit-callback-route', () => {
177
198
  it('should extract custom search params from returnPathname', async () => {
178
199
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
179
200
 
180
- const state = btoa(JSON.stringify({ returnPathname: '/custom-path?foo=bar&baz=qux' }));
201
+ const sealedState = await setAuthCookie(request, {
202
+ nonce: 'foo',
203
+ codeVerifier: 'test-verifier',
204
+ returnPathname: '/custom-path?foo=bar&baz=qux',
205
+ });
181
206
  request.nextUrl.searchParams.set('code', 'test-code');
182
- request.nextUrl.searchParams.set('state', state);
207
+ request.nextUrl.searchParams.set('state', sealedState);
183
208
 
184
209
  const handler = handleAuth();
185
210
  const response = await handler(request);
@@ -190,9 +215,14 @@ describe('authkit-callback-route', () => {
190
215
  it('should handle full URL in returnPathname by extracting only the pathname', async () => {
191
216
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
192
217
 
193
- const state = btoa(JSON.stringify({ returnPathname: 'https://example.com/invite/k0123456789' }));
218
+ const sealedState = await setAuthCookie(request, {
219
+ nonce: 'foo',
220
+ codeVerifier: 'test-verifier',
221
+ returnPathname: 'https://example.com/invite/k0123456789',
222
+ });
223
+
194
224
  request.nextUrl.searchParams.set('code', 'test-code');
195
- request.nextUrl.searchParams.set('state', state);
225
+ request.nextUrl.searchParams.set('state', sealedState);
196
226
 
197
227
  const handler = handleAuth();
198
228
  const response = await handler(request);
@@ -208,8 +238,9 @@ describe('authkit-callback-route', () => {
208
238
 
209
239
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
210
240
 
211
- // Set up request with code
241
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
212
242
  request.nextUrl.searchParams.set('code', 'test-code');
243
+ request.nextUrl.searchParams.set('state', sealedState);
213
244
 
214
245
  const handler = handleAuth();
215
246
  const response = await handler(request);
@@ -224,6 +255,10 @@ describe('authkit-callback-route', () => {
224
255
  const originalJson = NextResponse.json;
225
256
  (NextResponse as Partial<typeof NextResponse>).json = undefined;
226
257
 
258
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
259
+ request.nextUrl.searchParams.set('code', 'test-code');
260
+ request.nextUrl.searchParams.set('state', sealedState);
261
+
227
262
  const handler = handleAuth();
228
263
  const response = await handler(request);
229
264
 
@@ -240,8 +275,10 @@ describe('authkit-callback-route', () => {
240
275
  it('should use baseURL if provided', async () => {
241
276
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
242
277
 
243
- // Set up request with code
278
+ // Set up request with code & state
279
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
244
280
  request.nextUrl.searchParams.set('code', 'test-code');
281
+ request.nextUrl.searchParams.set('state', sealedState);
245
282
 
246
283
  const handler = handleAuth({ baseURL: 'https://base.com' });
247
284
  const response = await handler(request);
@@ -250,14 +287,15 @@ describe('authkit-callback-route', () => {
250
287
  });
251
288
 
252
289
  it('should throw an error if response is missing tokens', async () => {
253
- const mockAuthResponse = {
290
+ const incompleteAuthResponse = {
254
291
  user: { id: 'user_123' },
255
292
  };
256
293
 
257
- (workos.userManagement.authenticateWithCode as Mock).mockResolvedValue(mockAuthResponse);
294
+ (workos.userManagement.authenticateWithCode as Mock).mockResolvedValue(incompleteAuthResponse);
258
295
 
259
- // Set up request with code
296
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
260
297
  request.nextUrl.searchParams.set('code', 'test-code');
298
+ request.nextUrl.searchParams.set('state', sealedState);
261
299
 
262
300
  const handler = handleAuth();
263
301
  const response = await handler(request);
@@ -268,8 +306,10 @@ describe('authkit-callback-route', () => {
268
306
  it('should call onSuccess if provided', async () => {
269
307
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
270
308
 
271
- // Set up request with code
309
+ // Set up request with code & state
310
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
272
311
  request.nextUrl.searchParams.set('code', 'test-code');
312
+ request.nextUrl.searchParams.set('state', sealedState);
273
313
 
274
314
  const onSuccess = vi.fn();
275
315
  const handler = handleAuth({ onSuccess: onSuccess });
@@ -284,8 +324,9 @@ describe('authkit-callback-route', () => {
284
324
  const newAccessToken = 'new-access-token';
285
325
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
286
326
 
287
- // Set up request with code
327
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
288
328
  request.nextUrl.searchParams.set('code', 'test-code');
329
+ request.nextUrl.searchParams.set('state', sealedState);
289
330
 
290
331
  const handler = handleAuth({
291
332
  onSuccess: async (data) => {
@@ -301,15 +342,15 @@ describe('authkit-callback-route', () => {
301
342
  it('should pass custom state data to onSuccess callback', async () => {
302
343
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
303
344
 
304
- // Create state with new format: internal.user
305
- const internalState = btoa(JSON.stringify({ returnPathname: '/dashboard' }))
306
- .replace(/\+/g, '-')
307
- .replace(/\//g, '_');
308
- const userState = 'custom-user-state-string';
309
- const state = `${internalState}.${userState}`;
345
+ const sealedState = await setAuthCookie(request, {
346
+ nonce: 'foo',
347
+ codeVerifier: 'test-verifier',
348
+ returnPathname: '/dashboard',
349
+ customState: 'custom-user-state-string',
350
+ });
310
351
 
311
352
  request.nextUrl.searchParams.set('code', 'test-code');
312
- request.nextUrl.searchParams.set('state', state);
353
+ request.nextUrl.searchParams.set('state', sealedState);
313
354
 
314
355
  const onSuccess = vi.fn();
315
356
  const handler = handleAuth({ onSuccess });
@@ -332,10 +373,14 @@ describe('authkit-callback-route', () => {
332
373
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
333
374
 
334
375
  // State with only returnPathname
335
- const state = btoa(JSON.stringify({ returnPathname: '/profile' }));
376
+ const sealedState = await setAuthCookie(request, {
377
+ nonce: 'foo',
378
+ codeVerifier: 'test-verifier',
379
+ returnPathname: '/profile',
380
+ });
336
381
 
337
382
  request.nextUrl.searchParams.set('code', 'test-code');
338
- request.nextUrl.searchParams.set('state', state);
383
+ request.nextUrl.searchParams.set('state', sealedState);
339
384
 
340
385
  const onSuccess = vi.fn();
341
386
  const handler = handleAuth({ onSuccess });
@@ -350,51 +395,129 @@ describe('authkit-callback-route', () => {
350
395
  );
351
396
  });
352
397
 
353
- it('should handle backward compatibility with old state format', async () => {
398
+ it('should NOT handle backward compatibility with old state format', async () => {
354
399
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
355
400
 
356
401
  // Old format: just returnPathname
357
- const state = btoa(JSON.stringify({ returnPathname: '/old-path' }));
358
-
402
+ // @ts-expect-error we're purposely testing backward compatibility with an old format that doesn't match the current State interface
403
+ const sealedState = await setAuthCookie(request, { returnPathname: '/old-path' });
359
404
  request.nextUrl.searchParams.set('code', 'test-code');
360
- request.nextUrl.searchParams.set('state', state);
405
+ request.nextUrl.searchParams.set('state', sealedState);
361
406
 
362
407
  const handler = handleAuth();
363
408
  const response = await handler(request);
364
409
 
365
- // Should still redirect correctly
366
- expect(response.headers.get('Location')).toContain('/old-path');
410
+ // Should error
411
+ expect(response.status).toBe(500);
412
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it('should not leak nonce-only state as custom state in onSuccess', async () => {
416
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
417
+
418
+ // Simulate a nonce-only state (no returnPathname, no custom state)
419
+ const nonceState = await setAuthCookie(request, { nonce: 'test-nonce', codeVerifier: 'test-verifier' });
420
+ request.nextUrl.searchParams.set('code', 'test-code');
421
+ request.nextUrl.searchParams.set('state', nonceState);
422
+
423
+ const onSuccess = vi.fn();
424
+ const handler = handleAuth({ onSuccess });
425
+ await handler(request);
426
+
427
+ expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ state: undefined }));
367
428
  });
368
429
 
369
- describe('PKCE', () => {
370
- it('should pass codeVerifier from cookie to authenticateWithCode', async () => {
430
+ describe('state verification', () => {
431
+ it('should reject callback when state does not match stored state', async () => {
371
432
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
372
433
 
373
- // Seal a verifier into a cookie value
374
- const sealedVerifier = await sealData(
375
- { codeVerifier: 'test-verifier-123' },
434
+ const state = 'attacker-state';
435
+ request.nextUrl.searchParams.set('code', 'test-code');
436
+ request.nextUrl.searchParams.set('state', state);
437
+ await setAuthCookie(request, { nonce: 'legitimate-state', codeVerifier: 'test-verifier' });
438
+
439
+ const handler = handleAuth();
440
+ const response = await handler(request);
441
+
442
+ expect(response.status).toBe(500);
443
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
444
+ });
445
+
446
+ it('should reject when state is present but no cookie exists', async () => {
447
+ const sealedState = await sealData(
448
+ { nonce: 'foo', codeVerifier: 'test-verifier' },
376
449
  { password: process.env.WORKOS_COOKIE_PASSWORD! },
377
450
  );
451
+ request.nextUrl.searchParams.set('code', 'test-code');
452
+ request.nextUrl.searchParams.set('state', sealedState);
453
+
454
+ const handler = handleAuth();
455
+ const response = await handler(request);
378
456
 
379
- // Set the PKCE cookie on the request
380
- request.cookies.set('wos-pkce-verifier', sealedVerifier);
457
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
458
+ expect(response.status).toBe(500);
459
+ });
460
+
461
+ it('should pass when state matches stored state', async () => {
462
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
463
+
464
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
381
465
  request.nextUrl.searchParams.set('code', 'test-code');
466
+ request.nextUrl.searchParams.set('state', sealedState);
382
467
 
383
468
  const handler = handleAuth();
384
- await handler(request);
469
+ const response = await handler(request);
470
+
471
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalled();
472
+ expect(response.status).not.toBe(500);
473
+ });
474
+
475
+ it('should return 500 when neither state nor cookie exist', async () => {
476
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
477
+
478
+ request.nextUrl.searchParams.set('code', 'test-code');
479
+
480
+ const handler = handleAuth();
481
+ const response = await handler(request);
482
+
483
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
484
+ expect(response.status).toBe(500);
485
+ });
486
+ });
487
+
488
+ describe('PKCE', () => {
489
+ it('should pass codeVerifier and verify state when both are in the cookie', async () => {
490
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
491
+
492
+ const sealedState = await setAuthCookie(request, {
493
+ codeVerifier: 'test-verifier-456',
494
+ returnPathname: '/dashboard',
495
+ nonce: 'foo',
496
+ });
497
+ request.nextUrl.searchParams.set('code', 'test-code');
498
+ request.nextUrl.searchParams.set('state', sealedState);
499
+
500
+ const handler = handleAuth();
501
+ const response = await handler(request);
385
502
 
386
503
  expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
387
504
  expect.objectContaining({
388
505
  code: 'test-code',
389
- codeVerifier: 'test-verifier-123',
506
+ codeVerifier: 'test-verifier-456',
390
507
  }),
391
508
  );
509
+ expect(response.headers.get('Location')).toContain('/dashboard');
392
510
  });
393
511
 
394
- it('should proceed without codeVerifier when PKCE cookie is missing', async () => {
512
+ it('should pass codeVerifier from cookie to authenticateWithCode', async () => {
395
513
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
514
+ const sealedState = await setAuthCookie(request, {
515
+ nonce: 'foo',
516
+ codeVerifier: 'test-verifier-123',
517
+ });
396
518
 
397
519
  request.nextUrl.searchParams.set('code', 'test-code');
520
+ request.nextUrl.searchParams.set('state', sealedState);
398
521
 
399
522
  const handler = handleAuth();
400
523
  await handler(request);
@@ -402,46 +525,73 @@ describe('authkit-callback-route', () => {
402
525
  expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
403
526
  expect.objectContaining({
404
527
  code: 'test-code',
405
- codeVerifier: undefined,
528
+ codeVerifier: 'test-verifier-123',
406
529
  }),
407
530
  );
408
531
  });
409
532
 
410
- it('should proceed without codeVerifier when PKCE cookie is corrupted', async () => {
533
+ it('should reject when cookie is missing even if state contains valid sealed data', async () => {
534
+ const sealedState = await sealData(
535
+ { nonce: 'foo', codeVerifier: 'test-verifier-123' },
536
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
537
+ );
538
+ request.nextUrl.searchParams.set('code', 'test-code');
539
+ request.nextUrl.searchParams.set('state', sealedState);
540
+
541
+ const handler = handleAuth();
542
+ const response = await handler(request);
543
+
544
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
545
+ expect(response.status).toBe(500);
546
+ });
547
+
548
+ it('should return an error response when PKCE cookie is corrupted', async () => {
411
549
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
412
550
 
413
551
  // Set a corrupted cookie
414
- request.cookies.set('wos-pkce-verifier', 'not-a-valid-sealed-value');
552
+ request.cookies.set('wos-auth-verifier', 'not-a-valid-sealed-value');
415
553
  request.nextUrl.searchParams.set('code', 'test-code');
554
+ request.nextUrl.searchParams.set('state', 'not-a-valid-sealed-value');
416
555
 
417
556
  const handler = handleAuth();
418
- await handler(request);
557
+ const response = await handler(request);
419
558
 
420
- expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
421
- expect.objectContaining({
422
- code: 'test-code',
423
- codeVerifier: undefined,
424
- }),
425
- );
559
+ expect(response.status).toBe(500);
560
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
426
561
  });
427
562
 
428
563
  it('should delete PKCE cookie after successful authentication', async () => {
429
564
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
430
565
 
431
- const sealedVerifier = await sealData(
432
- { codeVerifier: 'test-verifier-123' },
433
- { password: process.env.WORKOS_COOKIE_PASSWORD! },
434
- );
435
-
436
- request.cookies.set('wos-pkce-verifier', sealedVerifier);
566
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
437
567
  request.nextUrl.searchParams.set('code', 'test-code');
568
+ request.nextUrl.searchParams.set('state', sealedState);
569
+
570
+ const handler = handleAuth();
571
+ const response = await handler(request);
572
+
573
+ // The response should be a redirect (success) and have a Set-Cookie header to delete the PKCE cookie
574
+ expect(response.status).toBe(307);
575
+
576
+ const setCookieHeaders = response.headers.getSetCookie();
577
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-auth-verifier='));
578
+ expect(pkceDeletionCookie).toBeDefined();
579
+ expect(pkceDeletionCookie).toContain('Max-Age=0');
580
+ });
581
+
582
+ it('should delete PKCE cookie after failed authentication', async () => {
583
+ vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue(new Error('Auth failed'));
584
+
585
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
586
+ request.nextUrl.searchParams.set('code', 'bad-code');
587
+ request.nextUrl.searchParams.set('state', sealedState);
438
588
 
439
589
  const handler = handleAuth();
440
590
  const response = await handler(request);
441
591
 
442
- // The response should have a Set-Cookie header to delete the PKCE cookie
592
+ expect(response.status).toBe(500);
443
593
  const setCookieHeaders = response.headers.getSetCookie();
444
- const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-pkce-verifier='));
594
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-auth-verifier='));
445
595
  expect(pkceDeletionCookie).toBeDefined();
446
596
  expect(pkceDeletionCookie).toContain('Max-Age=0');
447
597
  });