@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 '@testing-library/jest-dom';
2
3
  import { act, render, waitFor } from '@testing-library/react';
3
4
  import React from 'react';
@@ -89,7 +90,7 @@ describe('useAccessToken', () => {
89
90
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
90
91
 
91
92
  (getAccessTokenAction as Mock).mockResolvedValueOnce(expiringToken);
92
- (refreshAccessTokenAction as Mock).mockResolvedValueOnce(refreshedToken);
93
+ (refreshAccessTokenAction as Mock).mockResolvedValueOnce({ accessToken: refreshedToken });
93
94
 
94
95
  const { getByTestId } = render(<TestComponent />);
95
96
 
@@ -112,7 +113,7 @@ describe('useAccessToken', () => {
112
113
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
113
114
 
114
115
  (getAccessTokenAction as Mock).mockResolvedValueOnce(initialToken);
115
- (refreshAccessTokenAction as Mock).mockResolvedValueOnce(refreshedToken);
116
+ (refreshAccessTokenAction as Mock).mockResolvedValueOnce({ accessToken: refreshedToken });
116
117
 
117
118
  const { getByTestId } = render(<TestComponent />);
118
119
 
@@ -170,10 +171,12 @@ describe('useAccessToken', () => {
170
171
  it('should handle errors during manual refresh', async () => {
171
172
  const initialToken =
172
173
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
173
- const error = new Error('Failed to refresh token');
174
174
 
175
175
  (getAccessTokenAction as Mock).mockResolvedValueOnce(initialToken);
176
- (refreshAccessTokenAction as Mock).mockRejectedValueOnce(error);
176
+ (refreshAccessTokenAction as Mock).mockResolvedValueOnce({
177
+ accessToken: undefined,
178
+ error: 'Failed to refresh token',
179
+ });
177
180
 
178
181
  const { getByTestId } = render(<TestComponent />);
179
182
 
@@ -277,10 +280,12 @@ describe('useAccessToken', () => {
277
280
  iat: currentTimeInSeconds - 35,
278
281
  };
279
282
  const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
280
- const error = new Error('Failed to refresh token');
281
283
 
282
284
  (getAccessTokenAction as Mock).mockResolvedValueOnce(expiringToken);
283
- (refreshAccessTokenAction as Mock).mockRejectedValueOnce(error);
285
+ (refreshAccessTokenAction as Mock).mockResolvedValueOnce({
286
+ accessToken: undefined,
287
+ error: 'Failed to refresh token',
288
+ });
284
289
 
285
290
  const { getByTestId } = render(<TestComponent />);
286
291
 
@@ -410,7 +415,7 @@ describe('useAccessToken', () => {
410
415
 
411
416
  (refreshAccessTokenAction as Mock).mockImplementation(() => {
412
417
  refreshCalls++;
413
- return refreshPromise;
418
+ return refreshPromise.then((t) => ({ accessToken: t }));
414
419
  });
415
420
 
416
421
  (getAccessTokenAction as Mock).mockImplementation(() => {
@@ -507,7 +512,7 @@ describe('useAccessToken', () => {
507
512
  resolveRefreshPromise = resolve;
508
513
  });
509
514
 
510
- (refreshAccessTokenAction as Mock).mockReturnValue(refreshPromise);
515
+ (refreshAccessTokenAction as Mock).mockReturnValue(refreshPromise.then((t) => ({ accessToken: t })));
511
516
  (getAccessTokenAction as Mock).mockResolvedValue(initialToken);
512
517
 
513
518
  const { getByTestId } = render(<TestComponent />);
@@ -582,10 +587,8 @@ describe('useAccessToken', () => {
582
587
  const stringError = 'String error directly'; // Not wrapped in Error object
583
588
 
584
589
  (getAccessTokenAction as Mock).mockResolvedValueOnce(initialToken);
585
- // Mock refreshAccessTokenAction to reject with a string, not an Error object
586
- (refreshAccessTokenAction as Mock).mockImplementation(() => {
587
- return Promise.reject(stringError); // Directly reject with string
588
- });
590
+ // Mock refreshAccessTokenAction to return an error result (as server actions do)
591
+ (refreshAccessTokenAction as Mock).mockResolvedValueOnce({ accessToken: undefined, error: stringError });
589
592
 
590
593
  const { getByTestId } = render(<TestComponent />);
591
594
 
@@ -1,3 +1,4 @@
1
+ import type { Mock } from 'vitest';
1
2
  import '@testing-library/jest-dom';
2
3
  import { render, waitFor } from '@testing-library/react';
3
4
  import React from 'react';
package/src/cookie.ts CHANGED
@@ -92,6 +92,35 @@ export function getCookieOptions(
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * Cookie options for the PKCE verifier cookie.
97
+ * 'strict' blocks the cookie on the cross-site redirect back from WorkOS; downgrade to 'lax'.
98
+ * 'none' is more permissive and must be preserved for iframe/cross-origin embed flows.
99
+ */
100
+ export function getPKCECookieOptions(): CookieOptions;
101
+ export function getPKCECookieOptions(redirectUri: string | null | undefined, asString: true, expired?: boolean): string;
102
+ export function getPKCECookieOptions(
103
+ redirectUri?: string | null,
104
+ asString?: boolean,
105
+ expired?: boolean,
106
+ ): CookieOptions | string;
107
+ export function getPKCECookieOptions(
108
+ redirectUri?: string | null,
109
+ asString: boolean = false,
110
+ expired: boolean = false,
111
+ ): CookieOptions | string {
112
+ if (asString) {
113
+ const options = getCookieOptions(redirectUri, true, expired);
114
+ return options.replace(/SameSite=Strict/i, 'SameSite=Lax');
115
+ }
116
+
117
+ const options = getCookieOptions(redirectUri);
118
+ return {
119
+ ...options,
120
+ sameSite: options.sameSite.toLowerCase() === 'strict' ? 'lax' : options.sameSite,
121
+ };
122
+ }
123
+
95
124
  export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: string | null, expired?: boolean): string {
96
125
  const cookie = `${JWT_COOKIE_NAME}=${expired ? '' : (body ?? '')}`;
97
126
 
@@ -13,7 +13,6 @@ const WORKOS_COOKIE_MAX_AGE = getEnvVariable('WORKOS_COOKIE_MAX_AGE');
13
13
  const WORKOS_COOKIE_NAME = getEnvVariable('WORKOS_COOKIE_NAME');
14
14
  const WORKOS_COOKIE_SAMESITE = getEnvVariable('WORKOS_COOKIE_SAMESITE') as 'lax' | 'strict' | 'none' | undefined;
15
15
  const WORKOS_CLAIM_TOKEN = getEnvVariable('WORKOS_CLAIM_TOKEN');
16
- const WORKOS_ENABLE_PKCE = getEnvVariable('WORKOS_ENABLE_PKCE');
17
16
 
18
17
  // Required env variables
19
18
  const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? '';
@@ -34,5 +33,4 @@ export {
34
33
  WORKOS_COOKIE_PASSWORD,
35
34
  WORKOS_REDIRECT_URI,
36
35
  WORKOS_COOKIE_SAMESITE,
37
- WORKOS_ENABLE_PKCE,
38
36
  };
@@ -1,6 +1,7 @@
1
1
  import { getAuthorizationUrl } from './get-authorization-url.js';
2
2
  import { headers } from 'next/headers';
3
3
  import { getWorkOS } from './workos.js';
4
+ import { getStateFromPKCECookieValue } from './pkce.js';
4
5
 
5
6
  // Mock dependencies
6
7
  const { fakeWorkosInstance } = vi.hoisted(() => ({
@@ -27,7 +28,6 @@ describe('getAuthorizationUrl', () => {
27
28
  const workos = getWorkOS();
28
29
  beforeEach(() => {
29
30
  vi.clearAllMocks();
30
- delete process.env.WORKOS_ENABLE_PKCE;
31
31
  fakeWorkosInstance.pkce.generate.mockResolvedValue({
32
32
  codeVerifier: 'test-code-verifier',
33
33
  codeChallenge: 'test-code-challenge',
@@ -39,7 +39,6 @@ describe('getAuthorizationUrl', () => {
39
39
  const nextHeaders = await headers();
40
40
  nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
41
41
 
42
- // Mock workos.userManagement.getAuthorizationUrl
43
42
  vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
44
43
 
45
44
  await getAuthorizationUrl({});
@@ -54,7 +53,7 @@ describe('getAuthorizationUrl', () => {
54
53
  it('works when called with no arguments', async () => {
55
54
  vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
56
55
 
57
- await getAuthorizationUrl(); // Call with no arguments
56
+ await getAuthorizationUrl();
58
57
 
59
58
  expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled();
60
59
  });
@@ -160,9 +159,8 @@ describe('getAuthorizationUrl', () => {
160
159
  warnSpy.mockRestore();
161
160
  });
162
161
 
163
- it('works with PKCE disabled', async () => {
162
+ it('includes PKCE and claim nonce together', async () => {
164
163
  process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
165
- process.env.WORKOS_DISABLE_PKCE = 'true';
166
164
  const fetchSpy = vi
167
165
  .spyOn(globalThis, 'fetch')
168
166
  .mockResolvedValueOnce(new Response(JSON.stringify({ nonce: 'test-nonce' }), { status: 201 }));
@@ -174,39 +172,23 @@ describe('getAuthorizationUrl', () => {
174
172
  const result = await freshGetAuthorizationUrl({});
175
173
 
176
174
  expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
177
- expect.objectContaining({ claimNonce: 'test-nonce' }),
175
+ expect.objectContaining({
176
+ claimNonce: 'test-nonce',
177
+ codeChallenge: 'test-code-challenge',
178
+ codeChallengeMethod: 'S256',
179
+ }),
178
180
  );
179
- expect(result.pkceCookieValue).toBeUndefined();
181
+ expect(result.sealedState).toBeDefined();
180
182
  fetchSpy.mockRestore();
181
183
  });
182
184
  });
183
185
 
184
186
  describe('PKCE', () => {
185
- it('skips PKCE by default', async () => {
187
+ it('always generates PKCE pair and includes code challenge', async () => {
186
188
  vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
187
189
 
188
190
  const result = await getAuthorizationUrl({});
189
191
 
190
- expect(fakeWorkosInstance.pkce.generate).not.toHaveBeenCalled();
191
- expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
192
- expect.not.objectContaining({
193
- codeChallenge: expect.any(String),
194
- }),
195
- );
196
- expect(result.pkceCookieValue).toBeUndefined();
197
- });
198
-
199
- it('generates PKCE pair when WORKOS_ENABLE_PKCE is set to true', async () => {
200
- process.env.WORKOS_ENABLE_PKCE = 'true';
201
-
202
- // Re-import to pick up the new env var
203
- vi.resetModules();
204
- const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
205
-
206
- vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
207
-
208
- const result = await freshGetAuthorizationUrl({});
209
-
210
192
  expect(fakeWorkosInstance.pkce.generate).toHaveBeenCalled();
211
193
  expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
212
194
  expect.objectContaining({
@@ -215,23 +197,19 @@ describe('getAuthorizationUrl', () => {
215
197
  }),
216
198
  );
217
199
  expect(result.url).toBe('mock-url');
218
- expect(result.pkceCookieValue).toBeDefined();
219
- expect(result.pkceCookieValue).not.toBe('');
200
+ expect(result.sealedState).toBeDefined();
201
+ expect(result.sealedState).not.toBe('');
220
202
  });
221
203
 
222
- it('returns sealed cookie value for the verifier when PKCE is enabled', async () => {
223
- process.env.WORKOS_ENABLE_PKCE = 'true';
224
-
225
- vi.resetModules();
226
- const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
227
-
204
+ it('seals codeVerifier and nonce into state', async () => {
228
205
  vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
229
206
 
230
- const result = await freshGetAuthorizationUrl({});
207
+ const result = await getAuthorizationUrl({});
231
208
 
232
- // pkceCookieValue should be a sealed (encrypted) string
233
- expect(typeof result.pkceCookieValue).toBe('string');
234
- expect(result.pkceCookieValue!.length).toBeGreaterThan(0);
209
+ const { codeVerifier, nonce } = await getStateFromPKCECookieValue(result.sealedState);
210
+ expect(codeVerifier).toBe('test-code-verifier');
211
+ expect(nonce).toBeDefined();
212
+ expect(typeof nonce).toBe('string');
235
213
  });
236
214
  });
237
215
  });
@@ -1,13 +1,7 @@
1
1
  import { sealData } from 'iron-session';
2
2
  import { headers } from 'next/headers';
3
- import {
4
- WORKOS_CLAIM_TOKEN,
5
- WORKOS_CLIENT_ID,
6
- WORKOS_COOKIE_PASSWORD,
7
- WORKOS_ENABLE_PKCE,
8
- WORKOS_REDIRECT_URI,
9
- } from './env-variables.js';
10
- import { GetAuthURLOptions, GetAuthURLResult } from './interfaces.js';
3
+ import { WORKOS_CLAIM_TOKEN, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js';
4
+ import { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js';
11
5
  import { getWorkOS } from './workos.js';
12
6
 
13
7
  async function fetchClaimNonce(baseURL: string): Promise<string | null> {
@@ -39,51 +33,51 @@ async function fetchClaimNonce(baseURL: string): Promise<string | null> {
39
33
  }
40
34
  }
41
35
 
42
- async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
43
- const { returnPathname, screenHint, organizationId, loginHint, prompt, state: customState } = options;
44
- let redirectUri = options.redirectUri;
45
- if (!redirectUri) {
46
- const headersList = await headers();
47
- redirectUri = headersList.get('x-redirect-uri') ?? undefined;
48
- }
36
+ async function getAuthorizationUrl({
37
+ returnPathname,
38
+ screenHint,
39
+ organizationId,
40
+ loginHint,
41
+ prompt,
42
+ state: customState,
43
+ redirectUri,
44
+ }: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
45
+ const redirectUriToUse = await (async () => {
46
+ if (redirectUri) {
47
+ return redirectUri;
48
+ }
49
49
 
50
- const internalState = returnPathname
51
- ? btoa(JSON.stringify({ returnPathname })).replace(/\+/g, '-').replace(/\//g, '_')
52
- : null;
50
+ const headersList = await headers();
51
+ return headersList.get('x-redirect-uri') ?? undefined;
52
+ })();
53
53
 
54
+ const pkce = await getWorkOS().pkce.generate();
54
55
  const claimNonce = WORKOS_CLAIM_TOKEN ? await fetchClaimNonce(getWorkOS().baseURL) : null;
55
56
 
56
- const finalState =
57
- internalState && customState ? `${internalState}.${customState}` : internalState || customState || undefined;
57
+ const state = {
58
+ nonce: crypto.randomUUID(),
59
+ codeVerifier: pkce.codeVerifier,
60
+ customState,
61
+ returnPathname,
62
+ } satisfies State;
63
+
64
+ const sealedState = await sealData(state, { password: WORKOS_COOKIE_PASSWORD, ttl: 600 });
58
65
 
59
- const baseOptions = {
66
+ const url = getWorkOS().userManagement.getAuthorizationUrl({
60
67
  provider: 'authkit' as const,
61
68
  clientId: WORKOS_CLIENT_ID,
62
- redirectUri: redirectUri ?? WORKOS_REDIRECT_URI,
63
- state: finalState,
69
+ redirectUri: redirectUriToUse ?? WORKOS_REDIRECT_URI,
64
70
  screenHint,
65
71
  organizationId,
66
72
  loginHint,
67
73
  prompt,
74
+ state: sealedState,
75
+ codeChallenge: pkce.codeChallenge,
76
+ codeChallengeMethod: pkce.codeChallengeMethod,
68
77
  ...(claimNonce && { claimNonce }),
69
- };
70
-
71
- if (WORKOS_ENABLE_PKCE === 'true') {
72
- const pkce = await getWorkOS().pkce.generate();
73
- const pkceCookieValue = await sealData(
74
- { codeVerifier: pkce.codeVerifier },
75
- { password: WORKOS_COOKIE_PASSWORD, ttl: 600 },
76
- );
77
- const url = getWorkOS().userManagement.getAuthorizationUrl({
78
- ...baseOptions,
79
- codeChallenge: pkce.codeChallenge,
80
- codeChallengeMethod: pkce.codeChallengeMethod,
81
- });
82
-
83
- return { url, pkceCookieValue };
84
- }
78
+ });
85
79
 
86
- return { url: getWorkOS().userManagement.getAuthorizationUrl(baseOptions), pkceCookieValue: undefined };
80
+ return { url, sealedState };
87
81
  }
88
82
 
89
83
  export { getAuthorizationUrl };
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
2
2
  import { handleAuth } from './authkit-callback-route.js';
3
3
  import { AuthKitError, TokenRefreshError } from './errors.js';
4
- import { authkit, authkitMiddleware } from './middleware.js';
4
+ import { authkit, authkitMiddleware, authkitProxy } from './middleware.js';
5
5
  export {
6
6
  applyResponseHeaders,
7
7
  handleAuthkitHeaders,
8
+ handleAuthkitProxy,
8
9
  partitionAuthkitHeaders,
9
10
  isAuthkitRequestHeader,
10
11
  AUTHKIT_REQUEST_HEADERS,
@@ -24,6 +25,7 @@ export {
24
25
  TokenRefreshError,
25
26
  authkit,
26
27
  authkitMiddleware,
28
+ authkitProxy,
27
29
  getSignInUrl,
28
30
  getSignUpUrl,
29
31
  getTokenClaims,
package/src/interfaces.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AuthenticationResponse, OauthTokens, User } from '@workos-inc/node';
2
2
  import { type NextRequest } from 'next/server';
3
+ import * as v from 'valibot';
3
4
 
4
5
  export interface HandleAuthOptions {
5
6
  returnPathname?: string;
@@ -61,9 +62,18 @@ export interface AccessToken {
61
62
  feature_flags?: string[];
62
63
  }
63
64
 
65
+ export const StateSchema = v.object({
66
+ nonce: v.string(),
67
+ customState: v.optional(v.string()),
68
+ returnPathname: v.optional(v.string()),
69
+ codeVerifier: v.string(),
70
+ });
71
+
72
+ export type State = v.InferOutput<typeof StateSchema>;
73
+
64
74
  export interface GetAuthURLResult {
65
75
  url: string;
66
- pkceCookieValue?: string;
76
+ sealedState: string;
67
77
  }
68
78
 
69
79
  export interface GetAuthURLOptions {
package/src/jwt.ts CHANGED
@@ -2,16 +2,16 @@
2
2
  * JWT (JSON Web Token) Interface Definitions
3
3
  */
4
4
  export interface JWTHeader {
5
- 'alg': string;
6
- 'typ'?: string | undefined;
7
- 'cty'?: string | undefined;
8
- 'crit'?: Array<string | Exclude<keyof JWTHeader, 'crit'>> | undefined;
9
- 'kid'?: string | undefined;
10
- 'jku'?: string | undefined;
11
- 'x5u'?: string | string[] | undefined;
5
+ alg: string;
6
+ typ?: string | undefined;
7
+ cty?: string | undefined;
8
+ crit?: Array<string | Exclude<keyof JWTHeader, 'crit'>> | undefined;
9
+ kid?: string | undefined;
10
+ jku?: string | undefined;
11
+ x5u?: string | string[] | undefined;
12
12
  'x5t#S256'?: string | undefined;
13
- 'x5t'?: string | undefined;
14
- 'x5c'?: string | string[] | undefined;
13
+ x5t?: string | undefined;
14
+ x5c?: string | string[] | undefined;
15
15
  }
16
16
  /**
17
17
  * JWT Payload Interface
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
2
  import {
3
3
  handleAuthkitHeaders,
4
+ handleAuthkitProxy,
4
5
  partitionAuthkitHeaders,
5
6
  applyResponseHeaders,
6
7
  isAuthkitRequestHeader,
@@ -228,4 +229,10 @@ describe('middleware-helpers', () => {
228
229
  expect(response.headers.getSetCookie()).toHaveLength(2);
229
230
  });
230
231
  });
232
+
233
+ describe('handleAuthkitHeaders (deprecated alias)', () => {
234
+ it('should be the same function reference as handleAuthkitProxy', () => {
235
+ expect(handleAuthkitHeaders).toBe(handleAuthkitProxy);
236
+ });
237
+ });
231
238
  });
@@ -56,8 +56,9 @@ export function partitionAuthkitHeaders(request: NextRequest, authkitHeaders: He
56
56
  const headers = new Headers(authkitHeaders);
57
57
  const requestHeaders = new Headers(request.headers);
58
58
 
59
- // Strip any client-injected authkit headers, then apply trusted ones
60
- for (const name of [...requestHeaders.keys()]) {
59
+ // Snapshot keys before iterating, since we delete headers during the loop
60
+ const requestHeaderKeys = Array.from(requestHeaders.keys());
61
+ for (const name of requestHeaderKeys) {
61
62
  if (isAuthkitRequestHeader(name)) {
62
63
  requestHeaders.delete(name);
63
64
  }
@@ -106,7 +107,7 @@ export interface HandleAuthkitHeadersOptions {
106
107
  /**
107
108
  * Creates a NextResponse with properly merged AuthKit headers.
108
109
  */
109
- export function handleAuthkitHeaders(
110
+ export function handleAuthkitProxy(
110
111
  request: NextRequest,
111
112
  authkitHeaders: Headers,
112
113
  options: HandleAuthkitHeadersOptions = {},
@@ -128,3 +129,6 @@ export function handleAuthkitHeaders(
128
129
 
129
130
  return applyResponseHeaders(NextResponse.next({ request: { headers: requestHeaders } }), responseHeaders);
130
131
  }
132
+
133
+ /** @deprecated Use `handleAuthkitProxy` instead. */
134
+ export const handleAuthkitHeaders: typeof handleAuthkitProxy = handleAuthkitProxy;
@@ -0,0 +1,25 @@
1
+ import { authkitMiddleware, authkitProxy } from './middleware.js';
2
+
3
+ describe('middleware', () => {
4
+ describe('authkitProxy', () => {
5
+ it('should return a middleware function when called with no options', () => {
6
+ const middleware = authkitProxy();
7
+ expect(typeof middleware).toBe('function');
8
+ });
9
+
10
+ it('should accept the same options as authkitMiddleware', () => {
11
+ const middleware = authkitProxy({
12
+ debug: true,
13
+ middlewareAuth: { enabled: true, unauthenticatedPaths: ['/public'] },
14
+ signUpPaths: ['/sign-up'],
15
+ });
16
+ expect(typeof middleware).toBe('function');
17
+ });
18
+ });
19
+
20
+ describe('authkitMiddleware (deprecated alias)', () => {
21
+ it('should be the same function reference as authkitProxy', () => {
22
+ expect(authkitMiddleware).toBe(authkitProxy);
23
+ });
24
+ });
25
+ });
package/src/middleware.ts CHANGED
@@ -3,7 +3,7 @@ import { updateSessionMiddleware, updateSession } from './session.js';
3
3
  import { AuthkitMiddlewareOptions, AuthkitOptions, AuthkitResponse } from './interfaces.js';
4
4
  import { WORKOS_REDIRECT_URI } from './env-variables.js';
5
5
 
6
- export function authkitMiddleware({
6
+ export function authkitProxy({
7
7
  debug = false,
8
8
  middlewareAuth = { enabled: false, unauthenticatedPaths: [] },
9
9
  redirectUri = WORKOS_REDIRECT_URI,
@@ -15,6 +15,9 @@ export function authkitMiddleware({
15
15
  };
16
16
  }
17
17
 
18
+ /** @deprecated Use `authkitProxy` instead. */
19
+ export const authkitMiddleware: typeof authkitProxy = authkitProxy;
20
+
18
21
  export async function authkit(request: NextRequest, options: AuthkitOptions = {}): Promise<AuthkitResponse> {
19
22
  return await updateSession(request, options);
20
23
  }
@@ -0,0 +1,125 @@
1
+ import { sealData } from 'iron-session';
2
+ import { getStateFromPKCECookieValue } from './pkce.js';
3
+
4
+ const PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!;
5
+
6
+ describe('setPKCECookie SameSite override', () => {
7
+ const mockSet = vi.fn();
8
+
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ vi.resetModules();
12
+ vi.doMock('next/headers', () => ({
13
+ cookies: async () => ({ set: mockSet, get: vi.fn(), getAll: vi.fn(), delete: vi.fn() }),
14
+ headers: async () => ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }),
15
+ }));
16
+ vi.doMock('./env-variables', async (importOriginal) => {
17
+ return { ...(await importOriginal<typeof import('./env-variables')>()) };
18
+ });
19
+ });
20
+
21
+ it('should downgrade strict to lax', async () => {
22
+ const envVars = await import('./env-variables');
23
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' });
24
+
25
+ const { setPKCECookie } = await import('./pkce');
26
+ await setPKCECookie('sealed-state');
27
+
28
+ expect(mockSet).toHaveBeenCalledWith(
29
+ 'wos-auth-verifier',
30
+ 'sealed-state',
31
+ expect.objectContaining({ sameSite: 'lax' }),
32
+ );
33
+ });
34
+
35
+ it('should preserve none for iframe/cross-origin flows', async () => {
36
+ const envVars = await import('./env-variables');
37
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' });
38
+
39
+ const { setPKCECookie } = await import('./pkce');
40
+ await setPKCECookie('sealed-state');
41
+
42
+ expect(mockSet).toHaveBeenCalledWith(
43
+ 'wos-auth-verifier',
44
+ 'sealed-state',
45
+ expect.objectContaining({ sameSite: 'none' }),
46
+ );
47
+ });
48
+
49
+ it('should downgrade mixed-case Strict to lax', async () => {
50
+ const envVars = await import('./env-variables');
51
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'Strict' });
52
+
53
+ const { setPKCECookie } = await import('./pkce');
54
+ await setPKCECookie('sealed-state');
55
+
56
+ expect(mockSet).toHaveBeenCalledWith(
57
+ 'wos-auth-verifier',
58
+ 'sealed-state',
59
+ expect.objectContaining({ sameSite: 'lax' }),
60
+ );
61
+ });
62
+
63
+ it('should default to lax when no SameSite configured', async () => {
64
+ const { setPKCECookie } = await import('./pkce');
65
+ await setPKCECookie('sealed-state');
66
+
67
+ expect(mockSet).toHaveBeenCalledWith(
68
+ 'wos-auth-verifier',
69
+ 'sealed-state',
70
+ expect.objectContaining({ sameSite: 'lax' }),
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('getStateFromPKCECookieValue', () => {
76
+ it('should unseal and validate a valid state', async () => {
77
+ const sealed = await sealData(
78
+ { nonce: 'test-nonce', codeVerifier: 'verifier-abc', returnPathname: '/dashboard', customState: 'custom' },
79
+ { password: PASSWORD },
80
+ );
81
+
82
+ const state = await getStateFromPKCECookieValue(sealed);
83
+
84
+ expect(state.nonce).toBe('test-nonce');
85
+ expect(state.returnPathname).toBe('/dashboard');
86
+ expect(state.customState).toBe('custom');
87
+ });
88
+
89
+ it('should unseal state with codeVerifier', async () => {
90
+ const sealed = await sealData({ nonce: 'test-nonce', codeVerifier: 'verifier-123' }, { password: PASSWORD });
91
+
92
+ const state = await getStateFromPKCECookieValue(sealed);
93
+
94
+ expect(state.nonce).toBe('test-nonce');
95
+ expect(state.codeVerifier).toBe('verifier-123');
96
+ });
97
+
98
+ it('should throw when codeVerifier is missing', async () => {
99
+ const sealed = await sealData({ nonce: 'test-nonce', returnPathname: '/dashboard' }, { password: PASSWORD });
100
+
101
+ await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
102
+ });
103
+
104
+ it('should throw when nonce is missing', async () => {
105
+ const sealed = await sealData(
106
+ { codeVerifier: 'verifier-abc', returnPathname: '/dashboard' },
107
+ { password: PASSWORD },
108
+ );
109
+
110
+ await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
111
+ });
112
+
113
+ it('should throw when sealed data is corrupted', async () => {
114
+ await expect(getStateFromPKCECookieValue('not-a-valid-sealed-value')).rejects.toThrow();
115
+ });
116
+
117
+ it('should throw when sealed with a different password', async () => {
118
+ const sealed = await sealData(
119
+ { nonce: 'test-nonce', codeVerifier: 'verifier-abc' },
120
+ { password: 'a-different-password-that-is-32-chars!' },
121
+ );
122
+
123
+ await expect(getStateFromPKCECookieValue(sealed)).rejects.toThrow();
124
+ });
125
+ });