@workos-inc/authkit-nextjs 3.0.0-beta.1 → 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 (106) hide show
  1. package/README.md +276 -102
  2. package/dist/esm/actions.js +35 -4
  3. package/dist/esm/actions.js.map +1 -1
  4. package/dist/esm/auth.js +51 -20
  5. package/dist/esm/auth.js.map +1 -1
  6. package/dist/esm/authkit-callback-route.js +82 -93
  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 +16 -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 +38 -0
  37. package/dist/esm/pkce.js.map +1 -0
  38. package/dist/esm/session.js +73 -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 +8 -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 +12 -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 +32 -34
  66. package/src/actions.spec.ts +94 -17
  67. package/src/actions.ts +44 -5
  68. package/src/auth.spec.ts +60 -29
  69. package/src/auth.ts +55 -41
  70. package/src/authkit-callback-route.spec.ts +310 -58
  71. package/src/authkit-callback-route.ts +106 -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 +14 -9
  83. package/src/cookie.ts +29 -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 +125 -0
  97. package/src/pkce.ts +42 -0
  98. package/src/session.spec.ts +87 -89
  99. package/src/session.ts +91 -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,29 @@
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';
4
5
  import { NextRequest, NextResponse } from 'next/server';
6
+ import { sealData } from 'iron-session';
5
7
 
6
- // Mocked in jest.setup.ts
8
+ // Mocked in vitest.setup.ts
7
9
  import { cookies, headers } from 'next/headers';
10
+ import { State } from './interfaces.js';
8
11
 
9
12
  // Mock dependencies
10
- const fakeWorkosInstance = {
11
- userManagement: {
12
- authenticateWithCode: jest.fn(),
13
- getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
13
+ const { fakeWorkosInstance } = vi.hoisted(() => ({
14
+ fakeWorkosInstance: {
15
+ userManagement: {
16
+ authenticateWithCode: vi.fn(),
17
+ getJwksUrl: vi.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
18
+ },
19
+ pkce: {
20
+ generate: vi.fn(),
21
+ },
14
22
  },
15
- };
23
+ }));
16
24
 
17
- jest.mock('../src/workos', () => ({
18
- getWorkOS: jest.fn(() => fakeWorkosInstance),
25
+ vi.mock('../src/workos', () => ({
26
+ getWorkOS: vi.fn(() => fakeWorkosInstance),
19
27
  }));
20
28
 
21
29
  describe('authkit-callback-route', () => {
@@ -34,9 +42,9 @@ describe('authkit-callback-route', () => {
34
42
  createdAt: '2024-01-01T00:00:00Z',
35
43
  updatedAt: '2024-01-01T00:00:00Z',
36
44
  lastSignInAt: '2024-01-01T00:00:00Z',
37
- locale: null,
38
45
  externalId: null,
39
46
  metadata: {},
47
+ locale: null,
40
48
  },
41
49
  oauthTokens: {
42
50
  accessToken: 'access123',
@@ -46,17 +54,23 @@ describe('authkit-callback-route', () => {
46
54
  },
47
55
  };
48
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
+
49
63
  describe('handleAuth', () => {
50
64
  let request: NextRequest;
51
65
 
52
66
  beforeAll(() => {
53
67
  // Silence console.error during tests
54
- jest.spyOn(console, 'error').mockImplementation(() => {});
68
+ vi.spyOn(console, 'error').mockImplementation(() => {});
55
69
  });
56
70
 
57
71
  beforeEach(async () => {
58
72
  // Reset all mocks
59
- jest.clearAllMocks();
73
+ vi.clearAllMocks();
60
74
 
61
75
  // Create a new request with searchParams
62
76
  request = new NextRequest(new URL('http://example.com/callback'));
@@ -72,10 +86,12 @@ describe('authkit-callback-route', () => {
72
86
  });
73
87
 
74
88
  it('should handle successful authentication', async () => {
75
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
89
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
76
90
 
77
- // Set up request with code
91
+ // Set up request with code & state
92
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
78
93
  request.nextUrl.searchParams.set('code', 'test-code');
94
+ request.nextUrl.searchParams.set('state', sealedState);
79
95
 
80
96
  const handler = handleAuth();
81
97
  const response = await handler(request);
@@ -83,15 +99,17 @@ describe('authkit-callback-route', () => {
83
99
  expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({
84
100
  clientId: process.env.WORKOS_CLIENT_ID,
85
101
  code: 'test-code',
102
+ codeVerifier: 'test-verifier',
86
103
  });
87
104
  expect(response).toBeInstanceOf(NextResponse);
88
105
  });
89
106
 
90
107
  it('should handle authentication failure', async () => {
91
- // Mock authentication failure
92
- (workos.userManagement.authenticateWithCode as jest.Mock).mockRejectedValue(new Error('Auth failed'));
108
+ (workos.userManagement.authenticateWithCode as Mock).mockRejectedValue(new Error('Auth failed'));
93
109
 
110
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
94
111
  request.nextUrl.searchParams.set('code', 'invalid-code');
112
+ request.nextUrl.searchParams.set('state', sealedState);
95
113
 
96
114
  const handler = handleAuth();
97
115
  const response = await handler(request);
@@ -102,10 +120,11 @@ describe('authkit-callback-route', () => {
102
120
  });
103
121
 
104
122
  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');
123
+ vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
107
124
 
125
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
108
126
  request.nextUrl.searchParams.set('code', 'invalid-code');
127
+ request.nextUrl.searchParams.set('state', sealedState);
109
128
 
110
129
  const handler = handleAuth();
111
130
  const response = await handler(request);
@@ -116,9 +135,11 @@ describe('authkit-callback-route', () => {
116
135
  });
117
136
 
118
137
  it('should handle authentication failure with custom onError handler', async () => {
119
- // Mock authentication failure
120
- jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
138
+ vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
139
+
140
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
121
141
  request.nextUrl.searchParams.set('code', 'invalid-code');
142
+ request.nextUrl.searchParams.set('state', sealedState);
122
143
 
123
144
  const handler = handleAuth({
124
145
  onError: () => {
@@ -145,9 +166,11 @@ describe('authkit-callback-route', () => {
145
166
  });
146
167
 
147
168
  it('should respect custom returnPathname', async () => {
148
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
169
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
149
170
 
171
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
150
172
  request.nextUrl.searchParams.set('code', 'test-code');
173
+ request.nextUrl.searchParams.set('state', sealedState);
151
174
 
152
175
  const handler = handleAuth({ returnPathname: '/dashboard' });
153
176
  const response = await handler(request);
@@ -156,11 +179,15 @@ describe('authkit-callback-route', () => {
156
179
  });
157
180
 
158
181
  it('should handle state parameter with returnPathname', async () => {
159
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
182
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
160
183
 
161
- 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
+ });
162
189
  request.nextUrl.searchParams.set('code', 'test-code');
163
- request.nextUrl.searchParams.set('state', state);
190
+ request.nextUrl.searchParams.set('state', sealedState);
164
191
 
165
192
  const handler = handleAuth();
166
193
  const response = await handler(request);
@@ -169,11 +196,15 @@ describe('authkit-callback-route', () => {
169
196
  });
170
197
 
171
198
  it('should extract custom search params from returnPathname', async () => {
172
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
199
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
173
200
 
174
- 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
+ });
175
206
  request.nextUrl.searchParams.set('code', 'test-code');
176
- request.nextUrl.searchParams.set('state', state);
207
+ request.nextUrl.searchParams.set('state', sealedState);
177
208
 
178
209
  const handler = handleAuth();
179
210
  const response = await handler(request);
@@ -181,14 +212,35 @@ describe('authkit-callback-route', () => {
181
212
  expect(response.headers.get('Location')).toContain('/custom-path?foo=bar&baz=qux');
182
213
  });
183
214
 
215
+ it('should handle full URL in returnPathname by extracting only the pathname', async () => {
216
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
217
+
218
+ const sealedState = await setAuthCookie(request, {
219
+ nonce: 'foo',
220
+ codeVerifier: 'test-verifier',
221
+ returnPathname: 'https://example.com/invite/k0123456789',
222
+ });
223
+
224
+ request.nextUrl.searchParams.set('code', 'test-code');
225
+ request.nextUrl.searchParams.set('state', sealedState);
226
+
227
+ const handler = handleAuth();
228
+ const response = await handler(request);
229
+
230
+ const location = response.headers.get('Location');
231
+ expect(location).toContain('/invite/k0123456789');
232
+ expect(location).not.toContain('https://example.com/invite');
233
+ });
234
+
184
235
  it('should use Response if NextResponse.redirect is not available', async () => {
185
236
  const originalRedirect = NextResponse.redirect;
186
237
  (NextResponse as Partial<typeof NextResponse>).redirect = undefined;
187
238
 
188
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
239
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
189
240
 
190
- // Set up request with code
241
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
191
242
  request.nextUrl.searchParams.set('code', 'test-code');
243
+ request.nextUrl.searchParams.set('state', sealedState);
192
244
 
193
245
  const handler = handleAuth();
194
246
  const response = await handler(request);
@@ -203,6 +255,10 @@ describe('authkit-callback-route', () => {
203
255
  const originalJson = NextResponse.json;
204
256
  (NextResponse as Partial<typeof NextResponse>).json = undefined;
205
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
+
206
262
  const handler = handleAuth();
207
263
  const response = await handler(request);
208
264
 
@@ -217,10 +273,12 @@ describe('authkit-callback-route', () => {
217
273
  });
218
274
 
219
275
  it('should use baseURL if provided', async () => {
220
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
276
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
221
277
 
222
- // Set up request with code
278
+ // Set up request with code & state
279
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
223
280
  request.nextUrl.searchParams.set('code', 'test-code');
281
+ request.nextUrl.searchParams.set('state', sealedState);
224
282
 
225
283
  const handler = handleAuth({ baseURL: 'https://base.com' });
226
284
  const response = await handler(request);
@@ -229,14 +287,15 @@ describe('authkit-callback-route', () => {
229
287
  });
230
288
 
231
289
  it('should throw an error if response is missing tokens', async () => {
232
- const mockAuthResponse = {
290
+ const incompleteAuthResponse = {
233
291
  user: { id: 'user_123' },
234
292
  };
235
293
 
236
- (workos.userManagement.authenticateWithCode as jest.Mock).mockResolvedValue(mockAuthResponse);
294
+ (workos.userManagement.authenticateWithCode as Mock).mockResolvedValue(incompleteAuthResponse);
237
295
 
238
- // Set up request with code
296
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
239
297
  request.nextUrl.searchParams.set('code', 'test-code');
298
+ request.nextUrl.searchParams.set('state', sealedState);
240
299
 
241
300
  const handler = handleAuth();
242
301
  const response = await handler(request);
@@ -245,12 +304,14 @@ describe('authkit-callback-route', () => {
245
304
  });
246
305
 
247
306
  it('should call onSuccess if provided', async () => {
248
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
307
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
249
308
 
250
- // Set up request with code
309
+ // Set up request with code & state
310
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
251
311
  request.nextUrl.searchParams.set('code', 'test-code');
312
+ request.nextUrl.searchParams.set('state', sealedState);
252
313
 
253
- const onSuccess = jest.fn();
314
+ const onSuccess = vi.fn();
254
315
  const handler = handleAuth({ onSuccess: onSuccess });
255
316
  await handler(request);
256
317
 
@@ -261,10 +322,11 @@ describe('authkit-callback-route', () => {
261
322
 
262
323
  it('should allow onSuccess to update session', async () => {
263
324
  const newAccessToken = 'new-access-token';
264
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
325
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
265
326
 
266
- // Set up request with code
327
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
267
328
  request.nextUrl.searchParams.set('code', 'test-code');
329
+ request.nextUrl.searchParams.set('state', sealedState);
268
330
 
269
331
  const handler = handleAuth({
270
332
  onSuccess: async (data) => {
@@ -278,19 +340,19 @@ describe('authkit-callback-route', () => {
278
340
  });
279
341
 
280
342
  it('should pass custom state data to onSuccess callback', async () => {
281
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
343
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
282
344
 
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}`;
345
+ const sealedState = await setAuthCookie(request, {
346
+ nonce: 'foo',
347
+ codeVerifier: 'test-verifier',
348
+ returnPathname: '/dashboard',
349
+ customState: 'custom-user-state-string',
350
+ });
289
351
 
290
352
  request.nextUrl.searchParams.set('code', 'test-code');
291
- request.nextUrl.searchParams.set('state', state);
353
+ request.nextUrl.searchParams.set('state', sealedState);
292
354
 
293
- const onSuccess = jest.fn();
355
+ const onSuccess = vi.fn();
294
356
  const handler = handleAuth({ onSuccess });
295
357
  await handler(request);
296
358
 
@@ -308,15 +370,19 @@ describe('authkit-callback-route', () => {
308
370
  });
309
371
 
310
372
  it('should handle state without custom data', async () => {
311
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
373
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
312
374
 
313
375
  // State with only returnPathname
314
- const state = btoa(JSON.stringify({ returnPathname: '/profile' }));
376
+ const sealedState = await setAuthCookie(request, {
377
+ nonce: 'foo',
378
+ codeVerifier: 'test-verifier',
379
+ returnPathname: '/profile',
380
+ });
315
381
 
316
382
  request.nextUrl.searchParams.set('code', 'test-code');
317
- request.nextUrl.searchParams.set('state', state);
383
+ request.nextUrl.searchParams.set('state', sealedState);
318
384
 
319
- const onSuccess = jest.fn();
385
+ const onSuccess = vi.fn();
320
386
  const handler = handleAuth({ onSuccess });
321
387
  await handler(request);
322
388
 
@@ -329,20 +395,206 @@ describe('authkit-callback-route', () => {
329
395
  );
330
396
  });
331
397
 
332
- it('should handle backward compatibility with old state format', async () => {
333
- jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
398
+ it('should NOT handle backward compatibility with old state format', async () => {
399
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
334
400
 
335
401
  // Old format: just returnPathname
336
- const state = btoa(JSON.stringify({ returnPathname: '/old-path' }));
337
-
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' });
338
404
  request.nextUrl.searchParams.set('code', 'test-code');
339
- request.nextUrl.searchParams.set('state', state);
405
+ request.nextUrl.searchParams.set('state', sealedState);
340
406
 
341
407
  const handler = handleAuth();
342
408
  const response = await handler(request);
343
409
 
344
- // Should still redirect correctly
345
- 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 }));
428
+ });
429
+
430
+ describe('state verification', () => {
431
+ it('should reject callback when state does not match stored state', async () => {
432
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
433
+
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' },
449
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
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);
456
+
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' });
465
+ request.nextUrl.searchParams.set('code', 'test-code');
466
+ request.nextUrl.searchParams.set('state', sealedState);
467
+
468
+ const handler = handleAuth();
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);
502
+
503
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
504
+ expect.objectContaining({
505
+ code: 'test-code',
506
+ codeVerifier: 'test-verifier-456',
507
+ }),
508
+ );
509
+ expect(response.headers.get('Location')).toContain('/dashboard');
510
+ });
511
+
512
+ it('should pass codeVerifier from cookie to authenticateWithCode', async () => {
513
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
514
+ const sealedState = await setAuthCookie(request, {
515
+ nonce: 'foo',
516
+ codeVerifier: 'test-verifier-123',
517
+ });
518
+
519
+ request.nextUrl.searchParams.set('code', 'test-code');
520
+ request.nextUrl.searchParams.set('state', sealedState);
521
+
522
+ const handler = handleAuth();
523
+ await handler(request);
524
+
525
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
526
+ expect.objectContaining({
527
+ code: 'test-code',
528
+ codeVerifier: 'test-verifier-123',
529
+ }),
530
+ );
531
+ });
532
+
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 () => {
549
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
550
+
551
+ // Set a corrupted cookie
552
+ request.cookies.set('wos-auth-verifier', 'not-a-valid-sealed-value');
553
+ request.nextUrl.searchParams.set('code', 'test-code');
554
+ request.nextUrl.searchParams.set('state', 'not-a-valid-sealed-value');
555
+
556
+ const handler = handleAuth();
557
+ const response = await handler(request);
558
+
559
+ expect(response.status).toBe(500);
560
+ expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
561
+ });
562
+
563
+ it('should delete PKCE cookie after successful authentication', async () => {
564
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
565
+
566
+ const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
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);
588
+
589
+ const handler = handleAuth();
590
+ const response = await handler(request);
591
+
592
+ expect(response.status).toBe(500);
593
+ const setCookieHeaders = response.headers.getSetCookie();
594
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-auth-verifier='));
595
+ expect(pkceDeletionCookie).toBeDefined();
596
+ expect(pkceDeletionCookie).toContain('Max-Age=0');
597
+ });
346
598
  });
347
599
  });
348
600
  });