@workos-inc/authkit-nextjs 3.0.0 → 4.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.
package/src/actions.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  'use server';
2
2
 
3
- import { signOut, switchToOrganization } from './auth.js';
3
+ import { getSignInUrl, signOut, switchToOrganization } from './auth.js';
4
4
  import { NoUserInfo, UserInfo, SwitchToOrganizationOptions } from './interfaces.js';
5
5
  import { refreshSession, withAuth } from './session.js';
6
- import { getAuthorizationUrl } from './get-authorization-url.js';
7
6
  import { getWorkOS } from './workos.js';
8
7
 
9
8
  export interface RefreshAccessTokenActionResult {
@@ -49,7 +48,7 @@ export const getAuthAction = async (options?: { ensureSignedIn?: boolean }) => {
49
48
  const sanitized = sanitize(auth);
50
49
 
51
50
  if (options?.ensureSignedIn && !auth.user) {
52
- const signInUrl = await getAuthorizationUrl({ screenHint: 'sign-in' });
51
+ const signInUrl = await getSignInUrl();
53
52
  return { ...sanitized, signInUrl };
54
53
  }
55
54
 
@@ -69,7 +68,7 @@ export const refreshAuthAction = async ({
69
68
  const sanitized = sanitize(auth);
70
69
 
71
70
  if (ensureSignedIn && !auth.user) {
72
- const signInUrl = await getAuthorizationUrl({ screenHint: 'sign-in' });
71
+ const signInUrl = await getSignInUrl();
73
72
  return { ...sanitized, signInUrl };
74
73
  }
75
74
 
package/src/auth.spec.ts CHANGED
@@ -274,6 +274,25 @@ describe('auth.ts', () => {
274
274
  expect(sessionCookie).toBeUndefined();
275
275
  });
276
276
 
277
+ it('should clear lingering PKCE verifier cookies (legacy and per-flow)', async () => {
278
+ const nextCookies = await cookies();
279
+ const nextHeaders = await headers();
280
+
281
+ nextHeaders.set('x-workos-middleware', 'true');
282
+ nextCookies.set('wos-session', 'foo');
283
+ nextCookies.set('wos-auth-verifier', 'legacy-state');
284
+ nextCookies.set('wos-auth-verifier-a1b2c3d4', 'flow-a-state');
285
+ nextCookies.set('wos-auth-verifier-deadbeef', 'flow-b-state');
286
+ nextCookies.set('unrelated-cookie', 'keep-me');
287
+
288
+ await signOut();
289
+
290
+ expect(nextCookies.get('wos-auth-verifier')).toBeUndefined();
291
+ expect(nextCookies.get('wos-auth-verifier-a1b2c3d4')).toBeUndefined();
292
+ expect(nextCookies.get('wos-auth-verifier-deadbeef')).toBeUndefined();
293
+ expect(nextCookies.get('unrelated-cookie')?.value).toBe('keep-me');
294
+ });
295
+
277
296
  describe('when given a `returnTo` parameter', () => {
278
297
  it('passes the `returnTo` through to the `getLogoutUrl` call', async () => {
279
298
  vi.spyOn(workos.userManagement, 'getLogoutUrl').mockReturnValue(
package/src/auth.ts CHANGED
@@ -5,10 +5,10 @@ import { revalidatePath, revalidateTag } from 'next/cache';
5
5
  import { cookies, headers } from 'next/headers';
6
6
  import { redirect } from 'next/navigation';
7
7
  import { WORKOS_COOKIE_NAME } from './env-variables.js';
8
- import { getCookieOptions } from './cookie.js';
8
+ import { getCookieOptions, getPKCECookieOptions } from './cookie.js';
9
9
  import { getAuthorizationUrl } from './get-authorization-url.js';
10
10
  import type { AccessToken, GetAuthURLOptions, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
11
- import { setPKCECookie } from './pkce.js';
11
+ import { PKCE_COOKIE_NAME, setPKCECookie } from './pkce.js';
12
12
  import { getSessionFromCookie, refreshSession, withAuth } from './session.js';
13
13
  import { getWorkOS } from './workos.js';
14
14
 
@@ -80,6 +80,24 @@ export async function signOut({ returnTo }: { returnTo?: string } = {}) {
80
80
  nextCookies.delete(cookieName);
81
81
  }
82
82
 
83
+ // Clear any lingering PKCE verifier cookies so orphans from abandoned
84
+ // flows don't accumulate toward HTTP 431 or confuse future sign-ins.
85
+ const pkceOptions = getPKCECookieOptions();
86
+ for (const { name } of nextCookies.getAll()) {
87
+ if (!name.startsWith(PKCE_COOKIE_NAME)) continue;
88
+ try {
89
+ nextCookies.delete({
90
+ name,
91
+ domain: pkceOptions.domain,
92
+ path: pkceOptions.path,
93
+ sameSite: pkceOptions.sameSite,
94
+ secure: pkceOptions.secure,
95
+ });
96
+ } catch {
97
+ nextCookies.delete(name);
98
+ }
99
+ }
100
+
83
101
  if (sessionId) {
84
102
  redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }));
85
103
  } else {
@@ -1,6 +1,7 @@
1
1
  import type { Mock } from 'vitest';
2
2
  import { getWorkOS } from './workos.js';
3
3
  import { handleAuth } from './authkit-callback-route.js';
4
+ import { getPKCECookieNameForState } from './pkce.js';
4
5
  import { getSessionFromCookie, saveSession } from './session.js';
5
6
  import { NextRequest, NextResponse } from 'next/server';
6
7
  import { sealData } from 'iron-session';
@@ -56,7 +57,7 @@ describe('authkit-callback-route', () => {
56
57
 
57
58
  async function setAuthCookie(req: NextRequest, state: State): Promise<string> {
58
59
  const sealedState = await sealData(state, { password: process.env.WORKOS_COOKIE_PASSWORD! });
59
- req.cookies.set('wos-auth-verifier', sealedState);
60
+ req.cookies.set(getPKCECookieNameForState(sealedState), sealedState);
60
61
  return sealedState;
61
62
  }
62
63
 
@@ -548,10 +549,11 @@ describe('authkit-callback-route', () => {
548
549
  it('should return an error response when PKCE cookie is corrupted', async () => {
549
550
  vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
550
551
 
551
- // Set a corrupted cookie
552
- request.cookies.set('wos-auth-verifier', 'not-a-valid-sealed-value');
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);
553
555
  request.nextUrl.searchParams.set('code', 'test-code');
554
- request.nextUrl.searchParams.set('state', 'not-a-valid-sealed-value');
556
+ request.nextUrl.searchParams.set('state', corruptedState);
555
557
 
556
558
  const handler = handleAuth();
557
559
  const response = await handler(request);
@@ -570,11 +572,12 @@ describe('authkit-callback-route', () => {
570
572
  const handler = handleAuth();
571
573
  const response = await handler(request);
572
574
 
573
- // The response should be a redirect (success) and have a Set-Cookie header to delete the PKCE cookie
575
+ // The response should be a redirect (success) and have a Set-Cookie header to delete the flow-specific PKCE cookie
574
576
  expect(response.status).toBe(307);
575
577
 
578
+ const flowCookieName = getPKCECookieNameForState(sealedState);
576
579
  const setCookieHeaders = response.headers.getSetCookie();
577
- const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-auth-verifier='));
580
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith(`${flowCookieName}=`));
578
581
  expect(pkceDeletionCookie).toBeDefined();
579
582
  expect(pkceDeletionCookie).toContain('Max-Age=0');
580
583
  });
@@ -590,11 +593,70 @@ describe('authkit-callback-route', () => {
590
593
  const response = await handler(request);
591
594
 
592
595
  expect(response.status).toBe(500);
596
+ const flowCookieName = getPKCECookieNameForState(sealedState);
593
597
  const setCookieHeaders = response.headers.getSetCookie();
594
- const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-auth-verifier='));
598
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith(`${flowCookieName}=`));
595
599
  expect(pkceDeletionCookie).toBeDefined();
596
600
  expect(pkceDeletionCookie).toContain('Max-Age=0');
597
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
+ });
598
660
  });
599
661
  });
600
662
  });
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
2
2
  import { getPKCECookieOptions } from './cookie.js';
3
3
  import { WORKOS_CLIENT_ID } from './env-variables.js';
4
4
  import { HandleAuthOptions } from './interfaces.js';
5
- import { PKCE_COOKIE_NAME, getStateFromPKCECookieValue } from './pkce.js';
5
+ import { PKCE_COOKIE_NAME, getPKCECookieNameForState, getStateFromPKCECookieValue } from './pkce.js';
6
6
  import { saveSession } from './session.js';
7
7
  import { errorResponseWithFallback, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
8
8
  import { getWorkOS } from './workos.js';
@@ -25,26 +25,29 @@ export function handleAuth(options: HandleAuthOptions = {}) {
25
25
  }
26
26
 
27
27
  return async function GET(request: NextRequest) {
28
- // Always delete the PKCE cookie after handling the callback, regardless of success or error
29
- // to avoid stale cookies affecting future auth attempts & prevent replays
30
- const deleteCookie = `${PKCE_COOKIE_NAME}=; ${getPKCECookieOptions(request.url, true, true)}`;
28
+ // Fall back to standard URL parsing when nextUrl is not available (e.g., vinext)
29
+ const requestUrl = request.nextUrl ?? new URL(request.url);
31
30
 
32
- // We want to catch any & all errors and respond the same way
33
- // Firstly, by destroying the 1-use PKCE cookie to prevent replay attacks
34
- // or stale cookies affecting future auth attempts
35
- try {
36
- // Fall back to standard URL parsing when nextUrl is not available (e.g., vinext)
37
- const requestUrl = request.nextUrl ?? new URL(request.url);
38
-
39
- // Gather mandatory information
40
- const code = requestUrl.searchParams.get('code');
41
- const state = requestUrl.searchParams.get('state');
42
- const pkceCookie = request.cookies.get(PKCE_COOKIE_NAME)?.value;
31
+ // Gather mandatory information
32
+ const code = requestUrl.searchParams.get('code');
33
+ const state = requestUrl.searchParams.get('state');
43
34
 
35
+ // We want to catch any & all errors and respond the same way, always
36
+ // destroying the 1-use PKCE cookie to prevent replay attacks or stale
37
+ // cookies affecting future auth attempts.
38
+ try {
44
39
  if (!code || !state) {
45
40
  throw new Error('Missing required auth parameter');
46
41
  }
47
42
 
43
+ // Derive the flow-specific cookie name from the state param so each
44
+ // concurrent auth flow reads/deletes its own cookie, not a shared one.
45
+ // Fall back to the legacy shared cookie name so in-flight OAuth flows
46
+ // started on v3.0.x don't fail on the first callback after upgrade.
47
+ // Safe to remove once v3.0.x is unsupported.
48
+ const pkceCookieName = getPKCECookieNameForState(state);
49
+ const pkceCookie = request.cookies.get(pkceCookieName)?.value ?? request.cookies.get(PKCE_COOKIE_NAME)?.value;
50
+
48
51
  // CSRF verification: both channels (cookie + URL state) must be present and match
49
52
  if (!pkceCookie) {
50
53
  throw new Error(
@@ -95,7 +98,10 @@ export function handleAuth(options: HandleAuthOptions = {}) {
95
98
  // This is to support Next.js 13.
96
99
  const response = redirectWithFallback(url.toString());
97
100
  preventCaching(response.headers);
98
- response.headers.append('Set-Cookie', deleteCookie);
101
+
102
+ // Always delete the PKCE cookie after handling the callback, regardless of success or error
103
+ // to avoid stale cookies affecting future auth attempts & prevent replays
104
+ response.headers.append('Set-Cookie', `${pkceCookieName}=; ${getPKCECookieOptions(request.url, true, true)}`);
99
105
 
100
106
  await saveSession({ accessToken, refreshToken, user, impersonator }, request);
101
107
 
@@ -116,7 +122,16 @@ export function handleAuth(options: HandleAuthOptions = {}) {
116
122
  } catch (error) {
117
123
  console.error('[AuthKit callback error]', error);
118
124
  const response = await errorResponse(request, error);
119
- response.headers.append('Set-Cookie', deleteCookie);
125
+
126
+ // Always delete the PKCE cookie after handling the callback, regardless of success or error
127
+ // to avoid stale cookies affecting future auth attempts & prevent replays
128
+ if (state) {
129
+ response.headers.append(
130
+ 'Set-Cookie',
131
+ `${getPKCECookieNameForState(state)}=; ${getPKCECookieOptions(request.url, true, true)}`,
132
+ );
133
+ }
134
+
120
135
  return response;
121
136
  }
122
137
  };
@@ -278,4 +278,53 @@ describe('cookie.ts', () => {
278
278
  expect(ipCookie).not.toContain('Secure');
279
279
  });
280
280
  });
281
+
282
+ describe('getPKCECookieOptions', () => {
283
+ it('should use 10-minute max-age, not the session cookie max-age', async () => {
284
+ const { getPKCECookieOptions } = await import('./cookie');
285
+
286
+ const options = getPKCECookieOptions();
287
+
288
+ expect(options).toEqual(expect.objectContaining({ maxAge: 600 }));
289
+ });
290
+
291
+ it('should use 10-minute max-age in string format', async () => {
292
+ const { getPKCECookieOptions } = await import('./cookie');
293
+
294
+ const options = getPKCECookieOptions('http://localhost:3000', true);
295
+
296
+ expect(options).toContain('Max-Age=600');
297
+ expect(options).not.toContain('Max-Age=34560000');
298
+ });
299
+
300
+ it('should use max-age 0 when expired in object format', async () => {
301
+ const { getPKCECookieOptions } = await import('./cookie');
302
+
303
+ const options = getPKCECookieOptions(undefined, false, true);
304
+
305
+ expect(options).toEqual(expect.objectContaining({ maxAge: 0 }));
306
+ });
307
+
308
+ it('should use max-age 0 when expired in string format', async () => {
309
+ const { getPKCECookieOptions } = await import('./cookie');
310
+
311
+ const options = getPKCECookieOptions('http://localhost:3000', true, true);
312
+
313
+ expect(options).toContain('Max-Age=0');
314
+ });
315
+
316
+ it('should downgrade SameSite=Strict to Lax', async () => {
317
+ const envVars = await import('./env-variables');
318
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' });
319
+
320
+ const { getPKCECookieOptions } = await import('./cookie');
321
+
322
+ const objectOptions = getPKCECookieOptions();
323
+ expect(objectOptions).toEqual(expect.objectContaining({ sameSite: 'lax' }));
324
+
325
+ const stringOptions = getPKCECookieOptions('http://localhost:3000', true);
326
+ expect(stringOptions).toContain('SameSite=Lax');
327
+ expect(stringOptions).not.toContain('SameSite=Strict');
328
+ });
329
+ });
281
330
  });
package/src/cookie.ts CHANGED
@@ -92,10 +92,13 @@ export function getCookieOptions(
92
92
  };
93
93
  }
94
94
 
95
+ const PKCE_COOKIE_MAX_AGE = 600; // 10 minutes
96
+
95
97
  /**
96
98
  * Cookie options for the PKCE verifier cookie.
97
99
  * 'strict' blocks the cookie on the cross-site redirect back from WorkOS; downgrade to 'lax'.
98
100
  * 'none' is more permissive and must be preserved for iframe/cross-origin embed flows.
101
+ * Max-age is always capped to 10 minutes — PKCE cookies are single-use and short-lived.
99
102
  */
100
103
  export function getPKCECookieOptions(): CookieOptions;
101
104
  export function getPKCECookieOptions(redirectUri: string | null | undefined, asString: true, expired?: boolean): string;
@@ -111,13 +114,16 @@ export function getPKCECookieOptions(
111
114
  ): CookieOptions | string {
112
115
  if (asString) {
113
116
  const options = getCookieOptions(redirectUri, true, expired);
114
- return options.replace(/SameSite=Strict/i, 'SameSite=Lax');
117
+ return options
118
+ .replace(/SameSite=Strict/i, 'SameSite=Lax')
119
+ .replace(/Max-Age=\d+/, `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`);
115
120
  }
116
121
 
117
122
  const options = getCookieOptions(redirectUri);
118
123
  return {
119
124
  ...options,
120
125
  sameSite: options.sameSite.toLowerCase() === 'strict' ? 'lax' : options.sameSite,
126
+ maxAge: expired ? 0 : PKCE_COOKIE_MAX_AGE,
121
127
  };
122
128
  }
123
129
 
package/src/pkce.spec.ts CHANGED
@@ -1,8 +1,29 @@
1
1
  import { sealData } from 'iron-session';
2
- import { getStateFromPKCECookieValue } from './pkce.js';
2
+ import { getStateFromPKCECookieValue, getPKCECookieNameForState } from './pkce.js';
3
3
 
4
4
  const PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!;
5
5
 
6
+ describe('getPKCECookieNameForState', () => {
7
+ it('should derive a cookie name prefixed with the base name', () => {
8
+ const state = 'any-string-at-all';
9
+
10
+ expect(getPKCECookieNameForState(state)).toMatch(/^wos-auth-verifier-[0-9a-f]{8}$/);
11
+ });
12
+
13
+ it('should produce different names for different states', () => {
14
+ const stateA = 'first-sealed-state-value';
15
+ const stateB = 'second-sealed-state-value';
16
+
17
+ expect(getPKCECookieNameForState(stateA)).not.toBe(getPKCECookieNameForState(stateB));
18
+ });
19
+
20
+ it('should be deterministic for the same input', () => {
21
+ const state = 'some-sealed-state';
22
+
23
+ expect(getPKCECookieNameForState(state)).toBe(getPKCECookieNameForState(state));
24
+ });
25
+ });
26
+
6
27
  describe('setPKCECookie SameSite override', () => {
7
28
  const mockSet = vi.fn();
8
29
 
@@ -26,7 +47,7 @@ describe('setPKCECookie SameSite override', () => {
26
47
  await setPKCECookie('sealed-state');
27
48
 
28
49
  expect(mockSet).toHaveBeenCalledWith(
29
- 'wos-auth-verifier',
50
+ getPKCECookieNameForState('sealed-state'),
30
51
  'sealed-state',
31
52
  expect.objectContaining({ sameSite: 'lax' }),
32
53
  );
@@ -40,7 +61,7 @@ describe('setPKCECookie SameSite override', () => {
40
61
  await setPKCECookie('sealed-state');
41
62
 
42
63
  expect(mockSet).toHaveBeenCalledWith(
43
- 'wos-auth-verifier',
64
+ getPKCECookieNameForState('sealed-state'),
44
65
  'sealed-state',
45
66
  expect.objectContaining({ sameSite: 'none' }),
46
67
  );
@@ -54,7 +75,7 @@ describe('setPKCECookie SameSite override', () => {
54
75
  await setPKCECookie('sealed-state');
55
76
 
56
77
  expect(mockSet).toHaveBeenCalledWith(
57
- 'wos-auth-verifier',
78
+ getPKCECookieNameForState('sealed-state'),
58
79
  'sealed-state',
59
80
  expect.objectContaining({ sameSite: 'lax' }),
60
81
  );
@@ -65,7 +86,7 @@ describe('setPKCECookie SameSite override', () => {
65
86
  await setPKCECookie('sealed-state');
66
87
 
67
88
  expect(mockSet).toHaveBeenCalledWith(
68
- 'wos-auth-verifier',
89
+ getPKCECookieNameForState('sealed-state'),
69
90
  'sealed-state',
70
91
  expect.objectContaining({ sameSite: 'lax' }),
71
92
  );
package/src/pkce.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import fnv1a from '@sindresorhus/fnv1a';
1
2
  import { unsealData } from 'iron-session';
2
3
  import { cookies } from 'next/headers';
3
4
  import * as v from 'valibot';
@@ -6,7 +7,27 @@ import { WORKOS_COOKIE_PASSWORD } from './env-variables.js';
6
7
  import { State, StateSchema } from './interfaces.js';
7
8
 
8
9
  export const PKCE_COOKIE_NAME = 'wos-auth-verifier';
9
- const PKCE_COOKIE_MAX_AGE = 600; // 10 minutes
10
+
11
+ /**
12
+ * Short, deterministic hex fingerprint of an arbitrary string.
13
+ * Used to give each PKCE flow its own cookie name without depending
14
+ * on the internal format of the sealed state value
15
+ */
16
+ function shortHash(input: string): string {
17
+ // fnv1a returns a BigInt — use 32-bit variant so it fits safely in a Number
18
+ const hash = Number(fnv1a(input, { size: 32 }));
19
+
20
+ // Hex-encode and pad to a fixed 8-char width
21
+ return hash.toString(16).padStart(8, '0');
22
+ }
23
+
24
+ /**
25
+ * Derive a flow-specific cookie name so concurrent auth flows don't overwrite
26
+ * each other's PKCE cookies. Uses an FNV-1a hash of the full sealed state
27
+ */
28
+ export function getPKCECookieNameForState(state: string): string {
29
+ return `${PKCE_COOKIE_NAME}-${shortHash(state)}`;
30
+ }
10
31
 
11
32
  /**
12
33
  * Set the PKCE verifier cookie in server action context.
@@ -14,15 +35,11 @@ const PKCE_COOKIE_MAX_AGE = 600; // 10 minutes
14
35
  */
15
36
  export async function setPKCECookie(sealedState: string): Promise<void> {
16
37
  const nextCookies = await cookies();
17
- const { domain, path, sameSite, secure } = getPKCECookieOptions();
38
+ const options = getPKCECookieOptions();
18
39
 
19
- nextCookies.set(PKCE_COOKIE_NAME, sealedState, {
20
- domain,
21
- path,
22
- sameSite,
23
- secure,
40
+ nextCookies.set(getPKCECookieNameForState(sealedState), sealedState, {
41
+ ...options,
24
42
  httpOnly: true,
25
- maxAge: PKCE_COOKIE_MAX_AGE,
26
43
  });
27
44
  }
28
45
 
package/src/session.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  Session,
19
19
  UserInfo,
20
20
  } from './interfaces.js';
21
- import { PKCE_COOKIE_NAME, setPKCECookie } from './pkce.js';
21
+ import { getPKCECookieNameForState, setPKCECookie } from './pkce.js';
22
22
  import { getWorkOS } from './workos.js';
23
23
 
24
24
  import type { AuthenticationResponse } from '@workos-inc/node';
@@ -26,8 +26,21 @@ import { parse, tokensToRegexp } from 'path-to-regexp';
26
26
  import { handleAuthkitHeaders } from './middleware-helpers.js';
27
27
  import { lazy, setCachePreventionHeaders } from './utils.js';
28
28
 
29
- function appendPKCESetCookieHeader(headers: Headers, sealedState: string, requestUrl: string): void {
30
- headers.append('Set-Cookie', `${PKCE_COOKIE_NAME}=${sealedState}; ${getPKCECookieOptions(requestUrl, true)}`);
29
+ // Only set the PKCE cookie for initial document navigations — fetch/XHR/RSC/prefetch
30
+ // requests never follow cross-origin redirects so they'll never complete the OAuth
31
+ // flow and therefore don't need the cookie set.
32
+ // This prevents cookie bloat (HTTP 431) when multiple requests fire concurrently
33
+ // now that we are generating unique cookie names per flow, they add up quickly if
34
+ // we don't limit to just the initial navigation request
35
+ function appendPKCESetCookieHeader(request: NextRequest, headers: Headers, sealedState: string): void {
36
+ if (!isInitialDocumentRequest(request)) {
37
+ return;
38
+ }
39
+
40
+ headers.append(
41
+ 'Set-Cookie',
42
+ `${getPKCECookieNameForState(sealedState)}=${sealedState}; ${getPKCECookieOptions(request.url, true)}`,
43
+ );
31
44
  }
32
45
 
33
46
  const sessionHeaderName = 'x-workos-session';
@@ -213,7 +226,7 @@ async function updateSession(
213
226
  screenHint: options.screenHint,
214
227
  });
215
228
 
216
- appendPKCESetCookieHeader(newRequestHeaders, sealedState, request.url);
229
+ appendPKCESetCookieHeader(request, newRequestHeaders, sealedState);
217
230
 
218
231
  return {
219
232
  session: { user: null },
@@ -354,7 +367,7 @@ async function updateSession(
354
367
  redirectUri: options.redirectUri || WORKOS_REDIRECT_URI,
355
368
  });
356
369
 
357
- appendPKCESetCookieHeader(newRequestHeaders, sealedState, request.url);
370
+ appendPKCESetCookieHeader(request, newRequestHeaders, sealedState);
358
371
 
359
372
  return {
360
373
  session: { user: null },
@@ -16,7 +16,7 @@ describe('validate-api-key.ts', () => {
16
16
  nextHeaders._reset();
17
17
  });
18
18
 
19
- describe('validateApiKey', () => {
19
+ describe('createValidation', () => {
20
20
  it('should return valid API key when Bearer token is present and valid', async () => {
21
21
  const mockApiKeyResponse = {
22
22
  apiKey: {
@@ -32,14 +32,14 @@ describe('validate-api-key.ts', () => {
32
32
  },
33
33
  };
34
34
 
35
- vi.spyOn(workos.apiKeys, 'validateApiKey').mockResolvedValue(mockApiKeyResponse);
35
+ vi.spyOn(workos.apiKeys, 'createValidation').mockResolvedValue(mockApiKeyResponse);
36
36
 
37
37
  const nextHeaders = await headers();
38
38
  nextHeaders.set('authorization', 'Bearer sk_test_1234567890');
39
39
 
40
40
  const result = await validateApiKey();
41
41
 
42
- expect(workos.apiKeys.validateApiKey).toHaveBeenCalledWith({
42
+ expect(workos.apiKeys.createValidation).toHaveBeenCalledWith({
43
43
  value: 'sk_test_1234567890',
44
44
  });
45
45
  expect(result).toEqual(mockApiKeyResponse);
@@ -49,7 +49,7 @@ describe('validate-api-key.ts', () => {
49
49
  // Don't set any authorization header
50
50
  const result = await validateApiKey();
51
51
 
52
- expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
52
+ expect(workos.apiKeys.createValidation).not.toHaveBeenCalled();
53
53
  expect(result).toEqual({ apiKey: null });
54
54
  });
55
55
 
@@ -59,7 +59,7 @@ describe('validate-api-key.ts', () => {
59
59
 
60
60
  const result = await validateApiKey();
61
61
 
62
- expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
62
+ expect(workos.apiKeys.createValidation).not.toHaveBeenCalled();
63
63
  expect(result).toEqual({ apiKey: null });
64
64
  });
65
65
 
@@ -69,7 +69,7 @@ describe('validate-api-key.ts', () => {
69
69
 
70
70
  const result = await validateApiKey();
71
71
 
72
- expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
72
+ expect(workos.apiKeys.createValidation).not.toHaveBeenCalled();
73
73
  expect(result).toEqual({ apiKey: null });
74
74
  });
75
75
 
@@ -79,7 +79,7 @@ describe('validate-api-key.ts', () => {
79
79
 
80
80
  const result = await validateApiKey();
81
81
 
82
- expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
82
+ expect(workos.apiKeys.createValidation).not.toHaveBeenCalled();
83
83
  expect(result).toEqual({ apiKey: null });
84
84
  });
85
85
 
@@ -89,20 +89,20 @@ describe('validate-api-key.ts', () => {
89
89
 
90
90
  const result = await validateApiKey();
91
91
 
92
- expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
92
+ expect(workos.apiKeys.createValidation).not.toHaveBeenCalled();
93
93
  expect(result).toEqual({ apiKey: null });
94
94
  });
95
95
 
96
96
  it('should return { apiKey: null } when WorkOS validation fails', async () => {
97
97
  const mockResponse = { apiKey: null };
98
- vi.spyOn(workos.apiKeys, 'validateApiKey').mockResolvedValue(mockResponse);
98
+ vi.spyOn(workos.apiKeys, 'createValidation').mockResolvedValue(mockResponse);
99
99
 
100
100
  const nextHeaders = await headers();
101
101
  nextHeaders.set('authorization', 'Bearer invalid_key');
102
102
 
103
103
  const result = await validateApiKey();
104
104
 
105
- expect(workos.apiKeys.validateApiKey).toHaveBeenCalledWith({
105
+ expect(workos.apiKeys.createValidation).toHaveBeenCalledWith({
106
106
  value: 'invalid_key',
107
107
  });
108
108
  expect(result).toEqual({ apiKey: null });
@@ -15,5 +15,5 @@ export async function validateApiKey() {
15
15
  return { apiKey: null };
16
16
  }
17
17
 
18
- return getWorkOS().apiKeys.validateApiKey({ value });
18
+ return getWorkOS().apiKeys.createValidation({ value });
19
19
  }