@workos-inc/authkit-nextjs 3.0.0-beta.1 → 3.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.
Files changed (106) hide show
  1. package/README.md +305 -102
  2. package/dist/esm/actions.js +35 -5
  3. package/dist/esm/actions.js.map +1 -1
  4. package/dist/esm/auth.js +71 -21
  5. package/dist/esm/auth.js.map +1 -1
  6. package/dist/esm/authkit-callback-route.js +90 -92
  7. package/dist/esm/authkit-callback-route.js.map +1 -1
  8. package/dist/esm/components/authkit-provider.js +36 -15
  9. package/dist/esm/components/authkit-provider.js.map +1 -1
  10. package/dist/esm/components/impersonation.js +17 -15
  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 +20 -5
  21. package/dist/esm/cookie.js.map +1 -1
  22. package/dist/esm/env-variables.js +6 -6
  23. package/dist/esm/env-variables.js.map +1 -1
  24. package/dist/esm/errors.js +36 -0
  25. package/dist/esm/errors.js.map +1 -0
  26. package/dist/esm/get-authorization-url.js +51 -12
  27. package/dist/esm/get-authorization-url.js.map +1 -1
  28. package/dist/esm/index.js +5 -2
  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 +102 -0
  33. package/dist/esm/middleware-helpers.js.map +1 -0
  34. package/dist/esm/middleware.js +3 -1
  35. package/dist/esm/middleware.js.map +1 -1
  36. package/dist/esm/pkce.js +52 -0
  37. package/dist/esm/pkce.js.map +1 -0
  38. package/dist/esm/session.js +82 -35
  39. package/dist/esm/session.js.map +1 -1
  40. package/dist/esm/test-helpers.js +1 -1
  41. package/dist/esm/test-helpers.js.map +1 -1
  42. package/dist/esm/types/actions.d.ts +34 -5
  43. package/dist/esm/types/auth.d.ts +7 -15
  44. package/dist/esm/types/components/authkit-provider.d.ts +6 -2
  45. package/dist/esm/types/components/impersonation.d.ts +2 -1
  46. package/dist/esm/types/cookie.d.ts +9 -0
  47. package/dist/esm/types/env-variables.d.ts +2 -1
  48. package/dist/esm/types/errors.d.ts +15 -0
  49. package/dist/esm/types/get-authorization-url.d.ts +2 -2
  50. package/dist/esm/types/index.d.ts +5 -2
  51. package/dist/esm/types/interfaces.d.ts +12 -0
  52. package/dist/esm/types/jwt.d.ts +9 -9
  53. package/dist/esm/types/middleware-helpers.d.ts +27 -0
  54. package/dist/esm/types/middleware.d.ts +3 -1
  55. package/dist/esm/types/pkce.d.ts +17 -0
  56. package/dist/esm/types/session.d.ts +1 -1
  57. package/dist/esm/types/utils.d.ts +5 -0
  58. package/dist/esm/types/validate-api-key.d.ts +1 -0
  59. package/dist/esm/types/workos.d.ts +1 -1
  60. package/dist/esm/utils.js +10 -2
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/validate-api-key.js +16 -0
  63. package/dist/esm/validate-api-key.js.map +1 -0
  64. package/dist/esm/workos.js +1 -1
  65. package/package.json +33 -34
  66. package/src/actions.spec.ts +91 -18
  67. package/src/actions.ts +44 -6
  68. package/src/auth.spec.ts +79 -29
  69. package/src/auth.ts +74 -42
  70. package/src/authkit-callback-route.spec.ts +372 -58
  71. package/src/authkit-callback-route.ts +121 -103
  72. package/src/components/authkit-provider.spec.tsx +264 -70
  73. package/src/components/authkit-provider.tsx +40 -15
  74. package/src/components/button.spec.tsx +4 -6
  75. package/src/components/impersonation.spec.tsx +152 -35
  76. package/src/components/impersonation.tsx +37 -30
  77. package/src/components/min-max-button.spec.tsx +2 -1
  78. package/src/components/tokenStore.spec.ts +59 -44
  79. package/src/components/tokenStore.ts +11 -3
  80. package/src/components/useAccessToken.spec.tsx +82 -83
  81. package/src/components/useTokenClaims.spec.tsx +23 -22
  82. package/src/cookie.spec.ts +63 -9
  83. package/src/cookie.ts +35 -0
  84. package/src/env-variables.ts +2 -0
  85. package/src/errors.spec.ts +108 -0
  86. package/src/errors.ts +46 -0
  87. package/src/get-authorization-url.spec.ts +170 -15
  88. package/src/get-authorization-url.ts +69 -23
  89. package/src/index.ts +20 -2
  90. package/src/interfaces.ts +15 -0
  91. package/src/jwt.ts +9 -9
  92. package/src/middleware-helpers.spec.ts +238 -0
  93. package/src/middleware-helpers.ts +134 -0
  94. package/src/middleware.spec.ts +25 -0
  95. package/src/middleware.ts +4 -1
  96. package/src/pkce.spec.ts +146 -0
  97. package/src/pkce.ts +59 -0
  98. package/src/session.spec.ts +87 -89
  99. package/src/session.ts +104 -27
  100. package/src/test-helpers.ts +1 -1
  101. package/src/utils.spec.ts +14 -31
  102. package/src/utils.ts +9 -0
  103. package/src/validate-api-key.spec.ts +111 -0
  104. package/src/validate-api-key.ts +19 -0
  105. package/src/workos.spec.ts +2 -2
  106. package/src/workos.ts +1 -1
@@ -1,21 +1,30 @@
1
+ import type { Mock } from 'vitest';
1
2
  import { getWorkOS } from './workos.js';
2
3
  import { handleAuth } from './authkit-callback-route.js';
4
+ import { getPKCECookieNameForState } from './pkce.js';
3
5
  import { getSessionFromCookie, saveSession } from './session.js';
4
6
  import { NextRequest, NextResponse } from 'next/server';
7
+ import { sealData } from 'iron-session';
5
8
 
6
- // Mocked in jest.setup.ts
9
+ // Mocked in vitest.setup.ts
7
10
  import { cookies, headers } from 'next/headers';
11
+ import { State } from './interfaces.js';
8
12
 
9
13
  // Mock dependencies
10
- const fakeWorkosInstance = {
11
- userManagement: {
12
- authenticateWithCode: jest.fn(),
13
- getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
14
+ const { fakeWorkosInstance } = vi.hoisted(() => ({
15
+ fakeWorkosInstance: {
16
+ userManagement: {
17
+ authenticateWithCode: vi.fn(),
18
+ getJwksUrl: vi.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
19
+ },
20
+ pkce: {
21
+ generate: vi.fn(),
22
+ },
14
23
  },
15
- };
24
+ }));
16
25
 
17
- jest.mock('../src/workos', () => ({
18
- getWorkOS: jest.fn(() => fakeWorkosInstance),
26
+ vi.mock('../src/workos', () => ({
27
+ getWorkOS: vi.fn(() => fakeWorkosInstance),
19
28
  }));
20
29
 
21
30
  describe('authkit-callback-route', () => {
@@ -34,9 +43,9 @@ describe('authkit-callback-route', () => {
34
43
  createdAt: '2024-01-01T00:00:00Z',
35
44
  updatedAt: '2024-01-01T00:00:00Z',
36
45
  lastSignInAt: '2024-01-01T00:00:00Z',
37
- locale: null,
38
46
  externalId: null,
39
47
  metadata: {},
48
+ locale: null,
40
49
  },
41
50
  oauthTokens: {
42
51
  accessToken: 'access123',
@@ -46,17 +55,23 @@ describe('authkit-callback-route', () => {
46
55
  },
47
56
  };
48
57
 
58
+ async function setAuthCookie(req: NextRequest, state: State): Promise<string> {
59
+ const sealedState = await sealData(state, { password: process.env.WORKOS_COOKIE_PASSWORD! });
60
+ req.cookies.set(getPKCECookieNameForState(sealedState), sealedState);
61
+ return sealedState;
62
+ }
63
+
49
64
  describe('handleAuth', () => {
50
65
  let request: NextRequest;
51
66
 
52
67
  beforeAll(() => {
53
68
  // Silence console.error during tests
54
- jest.spyOn(console, 'error').mockImplementation(() => {});
69
+ vi.spyOn(console, 'error').mockImplementation(() => {});
55
70
  });
56
71
 
57
72
  beforeEach(async () => {
58
73
  // Reset all mocks
59
- jest.clearAllMocks();
74
+ vi.clearAllMocks();
60
75
 
61
76
  // Create a new request with searchParams
62
77
  request = new NextRequest(new URL('http://example.com/callback'));
@@ -72,10 +87,12 @@ describe('authkit-callback-route', () => {
72
87
  });
73
88
 
74
89
  it('should handle successful authentication', async () => {
75
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
90
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
76
91
 
77
- // Set up request with code
92
+ // Set up request with code & state
93
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
78
94
  request.nextUrl.searchParams.set('code', 'test-code');
95
+ request.nextUrl.searchParams.set('state', sealedState);
79
96
 
80
97
  const handler = handleAuth();
81
98
  const response = await handler(request);
@@ -83,15 +100,17 @@ describe('authkit-callback-route', () => {
83
100
  expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({
84
101
  clientId: process.env.WORKOS_CLIENT_ID,
85
102
  code: 'test-code',
103
+ codeVerifier: 'test-verifier',
86
104
  });
87
105
  expect(response).toBeInstanceOf(NextResponse);
88
106
  });
89
107
 
90
108
  it('should handle authentication failure', async () => {
91
- // Mock authentication failure
92
- (workos.userManagement.authenticateWithCode as jest.Mock).mockRejectedValue(new Error('Auth failed'));
109
+ (workos.userManagement.authenticateWithCode as Mock).mockRejectedValue(new Error('Auth failed'));
93
110
 
111
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
94
112
  request.nextUrl.searchParams.set('code', 'invalid-code');
113
+ request.nextUrl.searchParams.set('state', sealedState);
95
114
 
96
115
  const handler = handleAuth();
97
116
  const response = await handler(request);
@@ -102,10 +121,11 @@ describe('authkit-callback-route', () => {
102
121
  });
103
122
 
104
123
  it('should handle authentication failure if a non-Error object is thrown', async () => {
105
- // Mock authentication failure
106
- jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
124
+ vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
107
125
 
126
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
108
127
  request.nextUrl.searchParams.set('code', 'invalid-code');
128
+ request.nextUrl.searchParams.set('state', sealedState);
109
129
 
110
130
  const handler = handleAuth();
111
131
  const response = await handler(request);
@@ -116,9 +136,11 @@ describe('authkit-callback-route', () => {
116
136
  });
117
137
 
118
138
  it('should handle authentication failure with custom onError handler', async () => {
119
- // Mock authentication failure
120
- jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
139
+ vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
140
+
141
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
121
142
  request.nextUrl.searchParams.set('code', 'invalid-code');
143
+ request.nextUrl.searchParams.set('state', sealedState);
122
144
 
123
145
  const handler = handleAuth({
124
146
  onError: () => {
@@ -145,9 +167,11 @@ describe('authkit-callback-route', () => {
145
167
  });
146
168
 
147
169
  it('should respect custom returnPathname', async () => {
148
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
170
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
149
171
 
172
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
150
173
  request.nextUrl.searchParams.set('code', 'test-code');
174
+ request.nextUrl.searchParams.set('state', sealedState);
151
175
 
152
176
  const handler = handleAuth({ returnPathname: '/dashboard' });
153
177
  const response = await handler(request);
@@ -156,11 +180,15 @@ describe('authkit-callback-route', () => {
156
180
  });
157
181
 
158
182
  it('should handle state parameter with returnPathname', async () => {
159
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
183
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
160
184
 
161
- const state = btoa(JSON.stringify({ returnPathname: '/custom-path' }));
185
+ const sealedState = await setAuthCookie(request, {
186
+ nonce: 'foo',
187
+ codeVerifier: 'test-verifier',
188
+ returnPathname: '/custom-path',
189
+ });
162
190
  request.nextUrl.searchParams.set('code', 'test-code');
163
- request.nextUrl.searchParams.set('state', state);
191
+ request.nextUrl.searchParams.set('state', sealedState);
164
192
 
165
193
  const handler = handleAuth();
166
194
  const response = await handler(request);
@@ -169,11 +197,15 @@ describe('authkit-callback-route', () => {
169
197
  });
170
198
 
171
199
  it('should extract custom search params from returnPathname', async () => {
172
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
200
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
173
201
 
174
- const state = btoa(JSON.stringify({ returnPathname: '/custom-path?foo=bar&baz=qux' }));
202
+ const sealedState = await setAuthCookie(request, {
203
+ nonce: 'foo',
204
+ codeVerifier: 'test-verifier',
205
+ returnPathname: '/custom-path?foo=bar&baz=qux',
206
+ });
175
207
  request.nextUrl.searchParams.set('code', 'test-code');
176
- request.nextUrl.searchParams.set('state', state);
208
+ request.nextUrl.searchParams.set('state', sealedState);
177
209
 
178
210
  const handler = handleAuth();
179
211
  const response = await handler(request);
@@ -181,14 +213,35 @@ describe('authkit-callback-route', () => {
181
213
  expect(response.headers.get('Location')).toContain('/custom-path?foo=bar&baz=qux');
182
214
  });
183
215
 
216
+ it('should handle full URL in returnPathname by extracting only the pathname', async () => {
217
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
218
+
219
+ const sealedState = await setAuthCookie(request, {
220
+ nonce: 'foo',
221
+ codeVerifier: 'test-verifier',
222
+ returnPathname: 'https://example.com/invite/k0123456789',
223
+ });
224
+
225
+ request.nextUrl.searchParams.set('code', 'test-code');
226
+ request.nextUrl.searchParams.set('state', sealedState);
227
+
228
+ const handler = handleAuth();
229
+ const response = await handler(request);
230
+
231
+ const location = response.headers.get('Location');
232
+ expect(location).toContain('/invite/k0123456789');
233
+ expect(location).not.toContain('https://example.com/invite');
234
+ });
235
+
184
236
  it('should use Response if NextResponse.redirect is not available', async () => {
185
237
  const originalRedirect = NextResponse.redirect;
186
238
  (NextResponse as Partial<typeof NextResponse>).redirect = undefined;
187
239
 
188
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
240
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
189
241
 
190
- // Set up request with code
242
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
191
243
  request.nextUrl.searchParams.set('code', 'test-code');
244
+ request.nextUrl.searchParams.set('state', sealedState);
192
245
 
193
246
  const handler = handleAuth();
194
247
  const response = await handler(request);
@@ -203,6 +256,10 @@ describe('authkit-callback-route', () => {
203
256
  const originalJson = NextResponse.json;
204
257
  (NextResponse as Partial<typeof NextResponse>).json = undefined;
205
258
 
259
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
260
+ request.nextUrl.searchParams.set('code', 'test-code');
261
+ request.nextUrl.searchParams.set('state', sealedState);
262
+
206
263
  const handler = handleAuth();
207
264
  const response = await handler(request);
208
265
 
@@ -217,10 +274,12 @@ describe('authkit-callback-route', () => {
217
274
  });
218
275
 
219
276
  it('should use baseURL if provided', async () => {
220
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
277
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
221
278
 
222
- // Set up request with code
279
+ // Set up request with code & state
280
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
223
281
  request.nextUrl.searchParams.set('code', 'test-code');
282
+ request.nextUrl.searchParams.set('state', sealedState);
224
283
 
225
284
  const handler = handleAuth({ baseURL: 'https://base.com' });
226
285
  const response = await handler(request);
@@ -229,14 +288,15 @@ describe('authkit-callback-route', () => {
229
288
  });
230
289
 
231
290
  it('should throw an error if response is missing tokens', async () => {
232
- const mockAuthResponse = {
291
+ const incompleteAuthResponse = {
233
292
  user: { id: 'user_123' },
234
293
  };
235
294
 
236
- (workos.userManagement.authenticateWithCode as jest.Mock).mockResolvedValue(mockAuthResponse);
295
+ (workos.userManagement.authenticateWithCode as Mock).mockResolvedValue(incompleteAuthResponse);
237
296
 
238
- // Set up request with code
297
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
239
298
  request.nextUrl.searchParams.set('code', 'test-code');
299
+ request.nextUrl.searchParams.set('state', sealedState);
240
300
 
241
301
  const handler = handleAuth();
242
302
  const response = await handler(request);
@@ -245,12 +305,14 @@ describe('authkit-callback-route', () => {
245
305
  });
246
306
 
247
307
  it('should call onSuccess if provided', async () => {
248
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
308
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
249
309
 
250
- // Set up request with code
310
+ // Set up request with code & state
311
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
251
312
  request.nextUrl.searchParams.set('code', 'test-code');
313
+ request.nextUrl.searchParams.set('state', sealedState);
252
314
 
253
- const onSuccess = jest.fn();
315
+ const onSuccess = vi.fn();
254
316
  const handler = handleAuth({ onSuccess: onSuccess });
255
317
  await handler(request);
256
318
 
@@ -261,10 +323,11 @@ describe('authkit-callback-route', () => {
261
323
 
262
324
  it('should allow onSuccess to update session', async () => {
263
325
  const newAccessToken = 'new-access-token';
264
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
326
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
265
327
 
266
- // Set up request with code
328
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
267
329
  request.nextUrl.searchParams.set('code', 'test-code');
330
+ request.nextUrl.searchParams.set('state', sealedState);
268
331
 
269
332
  const handler = handleAuth({
270
333
  onSuccess: async (data) => {
@@ -278,19 +341,19 @@ describe('authkit-callback-route', () => {
278
341
  });
279
342
 
280
343
  it('should pass custom state data to onSuccess callback', async () => {
281
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
344
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
282
345
 
283
- // Create state with new format: internal.user
284
- const internalState = btoa(JSON.stringify({ returnPathname: '/dashboard' }))
285
- .replace(/\+/g, '-')
286
- .replace(/\//g, '_');
287
- const userState = 'custom-user-state-string';
288
- const state = `${internalState}.${userState}`;
346
+ const sealedState = await setAuthCookie(request, {
347
+ nonce: 'foo',
348
+ codeVerifier: 'test-verifier',
349
+ returnPathname: '/dashboard',
350
+ customState: 'custom-user-state-string',
351
+ });
289
352
 
290
353
  request.nextUrl.searchParams.set('code', 'test-code');
291
- request.nextUrl.searchParams.set('state', state);
354
+ request.nextUrl.searchParams.set('state', sealedState);
292
355
 
293
- const onSuccess = jest.fn();
356
+ const onSuccess = vi.fn();
294
357
  const handler = handleAuth({ onSuccess });
295
358
  await handler(request);
296
359
 
@@ -308,15 +371,19 @@ describe('authkit-callback-route', () => {
308
371
  });
309
372
 
310
373
  it('should handle state without custom data', async () => {
311
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
374
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
312
375
 
313
376
  // State with only returnPathname
314
- const state = btoa(JSON.stringify({ returnPathname: '/profile' }));
377
+ const sealedState = await setAuthCookie(request, {
378
+ nonce: 'foo',
379
+ codeVerifier: 'test-verifier',
380
+ returnPathname: '/profile',
381
+ });
315
382
 
316
383
  request.nextUrl.searchParams.set('code', 'test-code');
317
- request.nextUrl.searchParams.set('state', state);
384
+ request.nextUrl.searchParams.set('state', sealedState);
318
385
 
319
- const onSuccess = jest.fn();
386
+ const onSuccess = vi.fn();
320
387
  const handler = handleAuth({ onSuccess });
321
388
  await handler(request);
322
389
 
@@ -329,20 +396,267 @@ describe('authkit-callback-route', () => {
329
396
  );
330
397
  });
331
398
 
332
- it('should handle backward compatibility with old state format', async () => {
333
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
399
+ it('should NOT handle backward compatibility with old state format', async () => {
400
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
334
401
 
335
402
  // Old format: just returnPathname
336
- const state = btoa(JSON.stringify({ returnPathname: '/old-path' }));
337
-
403
+ // @ts-expect-error we're purposely testing backward compatibility with an old format that doesn't match the current State interface
404
+ const sealedState = await setAuthCookie(request, { returnPathname: '/old-path' });
338
405
  request.nextUrl.searchParams.set('code', 'test-code');
339
- request.nextUrl.searchParams.set('state', state);
406
+ request.nextUrl.searchParams.set('state', sealedState);
340
407
 
341
408
  const handler = handleAuth();
342
409
  const response = await handler(request);
343
410
 
344
- // Should still redirect correctly
345
- expect(response.headers.get('Location')).toContain('/old-path');
411
+ // Should error
412
+ expect(response.status).toBe(500);
413
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
414
+ });
415
+
416
+ it('should not leak nonce-only state as custom state in onSuccess', async () => {
417
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
418
+
419
+ // Simulate a nonce-only state (no returnPathname, no custom state)
420
+ const nonceState = await setAuthCookie(request, { nonce: 'test-nonce', codeVerifier: 'test-verifier' });
421
+ request.nextUrl.searchParams.set('code', 'test-code');
422
+ request.nextUrl.searchParams.set('state', nonceState);
423
+
424
+ const onSuccess = vi.fn();
425
+ const handler = handleAuth({ onSuccess });
426
+ await handler(request);
427
+
428
+ expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ state: undefined }));
429
+ });
430
+
431
+ describe('state verification', () => {
432
+ it('should reject callback when state does not match stored state', async () => {
433
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
434
+
435
+ const state = 'attacker-state';
436
+ request.nextUrl.searchParams.set('code', 'test-code');
437
+ request.nextUrl.searchParams.set('state', state);
438
+ await setAuthCookie(request, { nonce: 'legitimate-state', codeVerifier: 'test-verifier' });
439
+
440
+ const handler = handleAuth();
441
+ const response = await handler(request);
442
+
443
+ expect(response.status).toBe(500);
444
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
445
+ });
446
+
447
+ it('should reject when state is present but no cookie exists', async () => {
448
+ const sealedState = await sealData(
449
+ { nonce: 'foo', codeVerifier: 'test-verifier' },
450
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
451
+ );
452
+ request.nextUrl.searchParams.set('code', 'test-code');
453
+ request.nextUrl.searchParams.set('state', sealedState);
454
+
455
+ const handler = handleAuth();
456
+ const response = await handler(request);
457
+
458
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
459
+ expect(response.status).toBe(500);
460
+ });
461
+
462
+ it('should pass when state matches stored state', async () => {
463
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
464
+
465
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
466
+ request.nextUrl.searchParams.set('code', 'test-code');
467
+ request.nextUrl.searchParams.set('state', sealedState);
468
+
469
+ const handler = handleAuth();
470
+ const response = await handler(request);
471
+
472
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalled();
473
+ expect(response.status).not.toBe(500);
474
+ });
475
+
476
+ it('should return 500 when neither state nor cookie exist', async () => {
477
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
478
+
479
+ request.nextUrl.searchParams.set('code', 'test-code');
480
+
481
+ const handler = handleAuth();
482
+ const response = await handler(request);
483
+
484
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
485
+ expect(response.status).toBe(500);
486
+ });
487
+ });
488
+
489
+ describe('PKCE', () => {
490
+ it('should pass codeVerifier and verify state when both are in the cookie', async () => {
491
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
492
+
493
+ const sealedState = await setAuthCookie(request, {
494
+ codeVerifier: 'test-verifier-456',
495
+ returnPathname: '/dashboard',
496
+ nonce: 'foo',
497
+ });
498
+ request.nextUrl.searchParams.set('code', 'test-code');
499
+ request.nextUrl.searchParams.set('state', sealedState);
500
+
501
+ const handler = handleAuth();
502
+ const response = await handler(request);
503
+
504
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
505
+ expect.objectContaining({
506
+ code: 'test-code',
507
+ codeVerifier: 'test-verifier-456',
508
+ }),
509
+ );
510
+ expect(response.headers.get('Location')).toContain('/dashboard');
511
+ });
512
+
513
+ it('should pass codeVerifier from cookie to authenticateWithCode', async () => {
514
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
515
+ const sealedState = await setAuthCookie(request, {
516
+ nonce: 'foo',
517
+ codeVerifier: 'test-verifier-123',
518
+ });
519
+
520
+ request.nextUrl.searchParams.set('code', 'test-code');
521
+ request.nextUrl.searchParams.set('state', sealedState);
522
+
523
+ const handler = handleAuth();
524
+ await handler(request);
525
+
526
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
527
+ expect.objectContaining({
528
+ code: 'test-code',
529
+ codeVerifier: 'test-verifier-123',
530
+ }),
531
+ );
532
+ });
533
+
534
+ it('should reject when cookie is missing even if state contains valid sealed data', async () => {
535
+ const sealedState = await sealData(
536
+ { nonce: 'foo', codeVerifier: 'test-verifier-123' },
537
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
538
+ );
539
+ request.nextUrl.searchParams.set('code', 'test-code');
540
+ request.nextUrl.searchParams.set('state', sealedState);
541
+
542
+ const handler = handleAuth();
543
+ const response = await handler(request);
544
+
545
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
546
+ expect(response.status).toBe(500);
547
+ });
548
+
549
+ it('should return an error response when PKCE cookie is corrupted', async () => {
550
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
551
+
552
+ // Set a corrupted cookie using the flow-specific name
553
+ const corruptedState = 'not-a-valid-sealed-value';
554
+ request.cookies.set(getPKCECookieNameForState(corruptedState), corruptedState);
555
+ request.nextUrl.searchParams.set('code', 'test-code');
556
+ request.nextUrl.searchParams.set('state', corruptedState);
557
+
558
+ const handler = handleAuth();
559
+ const response = await handler(request);
560
+
561
+ expect(response.status).toBe(500);
562
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it('should delete PKCE cookie after successful authentication', async () => {
566
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
567
+
568
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
569
+ request.nextUrl.searchParams.set('code', 'test-code');
570
+ request.nextUrl.searchParams.set('state', sealedState);
571
+
572
+ const handler = handleAuth();
573
+ const response = await handler(request);
574
+
575
+ // The response should be a redirect (success) and have a Set-Cookie header to delete the flow-specific PKCE cookie
576
+ expect(response.status).toBe(307);
577
+
578
+ const flowCookieName = getPKCECookieNameForState(sealedState);
579
+ const setCookieHeaders = response.headers.getSetCookie();
580
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith(`${flowCookieName}=`));
581
+ expect(pkceDeletionCookie).toBeDefined();
582
+ expect(pkceDeletionCookie).toContain('Max-Age=0');
583
+ });
584
+
585
+ it('should delete PKCE cookie after failed authentication', async () => {
586
+ vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue(new Error('Auth failed'));
587
+
588
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
589
+ request.nextUrl.searchParams.set('code', 'bad-code');
590
+ request.nextUrl.searchParams.set('state', sealedState);
591
+
592
+ const handler = handleAuth();
593
+ const response = await handler(request);
594
+
595
+ expect(response.status).toBe(500);
596
+ const flowCookieName = getPKCECookieNameForState(sealedState);
597
+ const setCookieHeaders = response.headers.getSetCookie();
598
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith(`${flowCookieName}=`));
599
+ expect(pkceDeletionCookie).toBeDefined();
600
+ expect(pkceDeletionCookie).toContain('Max-Age=0');
601
+ });
602
+
603
+ it('should isolate concurrent auth flows using per-flow cookie names', async () => {
604
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
605
+
606
+ // Simulate two concurrent auth flows with different sealed states
607
+ const sealedStateA = await sealData(
608
+ { nonce: 'nonce-a', codeVerifier: 'verifier-a' },
609
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
610
+ );
611
+ const sealedStateB = await sealData(
612
+ { nonce: 'nonce-b', codeVerifier: 'verifier-b' },
613
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
614
+ );
615
+
616
+ // Both cookies exist on the request (set by different middleware redirects)
617
+ request.cookies.set(getPKCECookieNameForState(sealedStateA), sealedStateA);
618
+ request.cookies.set(getPKCECookieNameForState(sealedStateB), sealedStateB);
619
+
620
+ // Callback for flow A — should find its own cookie
621
+ request.nextUrl.searchParams.set('code', 'code-a');
622
+ request.nextUrl.searchParams.set('state', sealedStateA);
623
+
624
+ const handler = handleAuth();
625
+ const response = await handler(request);
626
+
627
+ expect(response.status).toBe(307);
628
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
629
+ expect.objectContaining({ codeVerifier: 'verifier-a' }),
630
+ );
631
+
632
+ // Flow B's cookie should NOT have been deleted
633
+ const setCookieHeaders = response.headers.getSetCookie();
634
+ const flowBCookieName = getPKCECookieNameForState(sealedStateB);
635
+ const flowBDeletion = setCookieHeaders.find((c: string) => c.startsWith(`${flowBCookieName}=`));
636
+ expect(flowBDeletion).toBeUndefined();
637
+ });
638
+
639
+ it('should fall back to the legacy shared PKCE cookie for v3.0.x in-flight flows', async () => {
640
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
641
+
642
+ const sealedState = await sealData(
643
+ { nonce: 'legacy', codeVerifier: 'legacy-verifier' },
644
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
645
+ );
646
+
647
+ // Simulate a user mid-OAuth on v3.0.x: only the legacy cookie name exists
648
+ request.cookies.set('wos-auth-verifier', sealedState);
649
+ request.nextUrl.searchParams.set('code', 'test-code');
650
+ request.nextUrl.searchParams.set('state', sealedState);
651
+
652
+ const handler = handleAuth();
653
+ const response = await handler(request);
654
+
655
+ expect(response.status).toBe(307);
656
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
657
+ expect.objectContaining({ codeVerifier: 'legacy-verifier' }),
658
+ );
659
+ });
346
660
  });
347
661
  });
348
662
  });