@workos-inc/authkit-nextjs 2.14.0 → 2.16.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/auth.spec.ts CHANGED
@@ -29,6 +29,13 @@ const fakeWorkosInstance = {
29
29
  getJwksUrl: vi.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
30
30
  getLogoutUrl: vi.fn(),
31
31
  },
32
+ pkce: {
33
+ generate: vi.fn().mockResolvedValue({
34
+ codeVerifier: 'test-code-verifier',
35
+ codeChallenge: 'test-code-challenge',
36
+ codeChallengeMethod: 'S256' as const,
37
+ }),
38
+ },
32
39
  };
33
40
 
34
41
  const revalidatePath = vi.mocked(cache.revalidatePath);
@@ -74,6 +81,15 @@ describe('auth.ts', () => {
74
81
  expect(url).toBeDefined();
75
82
  expect(() => new URL(url)).not.toThrow();
76
83
  });
84
+
85
+ it('should include returnTo as returnPathname in the state parameter', async () => {
86
+ const url = await getSignInUrl({ returnTo: '/dashboard' });
87
+ const parsedUrl = new URL(url);
88
+ const state = parsedUrl.searchParams.get('state');
89
+ expect(state).toBeDefined();
90
+ const decoded = JSON.parse(atob(state!.replace(/-/g, '+').replace(/_/g, '/')));
91
+ expect(decoded.returnPathname).toBe('/dashboard');
92
+ });
77
93
  });
78
94
 
79
95
  it('should not include prompt when not specified for getSignInUrl', async () => {
@@ -101,6 +117,15 @@ describe('auth.ts', () => {
101
117
  const url = await getSignUpUrl({ prompt: 'consent' });
102
118
  expect(url).toContain('prompt=consent');
103
119
  });
120
+
121
+ it('should include returnTo as returnPathname in the state parameter', async () => {
122
+ const url = await getSignUpUrl({ returnTo: '/welcome' });
123
+ const parsedUrl = new URL(url);
124
+ const state = parsedUrl.searchParams.get('state');
125
+ expect(state).toBeDefined();
126
+ const decoded = JSON.parse(atob(state!.replace(/-/g, '+').replace(/_/g, '/')));
127
+ expect(decoded.returnPathname).toBe('/welcome');
128
+ });
104
129
  });
105
130
 
106
131
  describe('switchToOrganization', () => {
@@ -133,9 +158,16 @@ describe('auth.ts', () => {
133
158
  nextHeaders.set('x-url', 'http://localhost/test');
134
159
  await generateSession();
135
160
 
161
+ fakeWorkosInstance.pkce.generate.mockResolvedValue({
162
+ codeVerifier: 'test-code-verifier',
163
+ codeChallenge: 'test-code-challenge',
164
+ codeChallengeMethod: 'S256' as const,
165
+ });
166
+
136
167
  // Create a WorkOS-like object that matches what our tests need
137
168
  const mockWorkOS = {
138
169
  userManagement: fakeWorkosInstance.userManagement,
170
+ pkce: fakeWorkosInstance.pkce,
139
171
  // Add minimal properties to satisfy TypeScript
140
172
  createHttpClient: vi.fn(),
141
173
  createWebhookClient: vi.fn(),
package/src/auth.ts CHANGED
@@ -7,7 +7,8 @@ import { redirect } from 'next/navigation';
7
7
  import { WORKOS_COOKIE_NAME } from './env-variables.js';
8
8
  import { getCookieOptions } from './cookie.js';
9
9
  import { getAuthorizationUrl } from './get-authorization-url.js';
10
- import type { AccessToken, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
10
+ import type { AccessToken, GetAuthURLOptions, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
11
+ import { setPKCECookie } from './pkce.js';
11
12
  import { getSessionFromCookie, refreshSession, withAuth } from './session.js';
12
13
  import { getWorkOS } from './workos.js';
13
14
 
@@ -20,20 +21,36 @@ function revalidateTagCompat(tag: string): void {
20
21
  return fn(tag, 'max');
21
22
  }
22
23
 
24
+ async function getAuthURLAndSetPKCECookie(options: GetAuthURLOptions): Promise<string> {
25
+ const { url, pkceCookieValue } = await getAuthorizationUrl(options);
26
+ await setPKCECookie(pkceCookieValue);
27
+ return url;
28
+ }
29
+
23
30
  export async function getSignInUrl({
24
31
  organizationId,
25
32
  loginHint,
26
33
  redirectUri,
27
34
  prompt,
28
35
  state,
36
+ returnTo,
29
37
  }: {
30
38
  organizationId?: string;
31
39
  loginHint?: string;
32
40
  redirectUri?: string;
33
41
  prompt?: 'consent';
34
42
  state?: string;
43
+ returnTo?: string;
35
44
  } = {}) {
36
- return getAuthorizationUrl({ organizationId, screenHint: 'sign-in', loginHint, redirectUri, prompt, state });
45
+ return getAuthURLAndSetPKCECookie({
46
+ organizationId,
47
+ screenHint: 'sign-in',
48
+ loginHint,
49
+ redirectUri,
50
+ prompt,
51
+ state,
52
+ returnPathname: returnTo,
53
+ });
37
54
  }
38
55
 
39
56
  export async function getSignUpUrl({
@@ -42,14 +59,24 @@ export async function getSignUpUrl({
42
59
  redirectUri,
43
60
  prompt,
44
61
  state,
62
+ returnTo,
45
63
  }: {
46
64
  organizationId?: string;
47
65
  loginHint?: string;
48
66
  redirectUri?: string;
49
67
  prompt?: 'consent';
50
68
  state?: string;
69
+ returnTo?: string;
51
70
  } = {}) {
52
- return getAuthorizationUrl({ organizationId, screenHint: 'sign-up', loginHint, redirectUri, prompt, state });
71
+ return getAuthURLAndSetPKCECookie({
72
+ organizationId,
73
+ screenHint: 'sign-up',
74
+ loginHint,
75
+ redirectUri,
76
+ prompt,
77
+ state,
78
+ returnPathname: returnTo,
79
+ });
53
80
  }
54
81
 
55
82
  /**
@@ -77,7 +104,12 @@ export async function signOut({ returnTo }: { returnTo?: string } = {}) {
77
104
  const nextCookies = await cookies();
78
105
  const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
79
106
  const { domain, path, sameSite, secure } = getCookieOptions();
80
- nextCookies.delete({ name: cookieName, domain, path, sameSite, secure });
107
+ try {
108
+ nextCookies.delete({ name: cookieName, domain, path, sameSite, secure });
109
+ } catch {
110
+ // Some environments (e.g., vinext) only accept a string cookie name
111
+ nextCookies.delete(cookieName);
112
+ }
81
113
 
82
114
  if (sessionId) {
83
115
  redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }));
@@ -108,22 +140,25 @@ export async function switchToOrganization(
108
140
  redirect(cause.rawData.authkit_redirect_url);
109
141
  } else {
110
142
  if (cause?.error === 'sso_required' || cause?.error === 'mfa_enrollment') {
111
- const url = await getAuthorizationUrl({ organizationId });
112
- return redirect(url);
143
+ return redirect(await getAuthURLAndSetPKCECookie({ organizationId }));
113
144
  }
114
145
  throw error;
115
146
  }
116
147
  }
117
148
 
118
- switch (revalidationStrategy) {
119
- case 'path':
120
- revalidatePath(pathname);
121
- break;
122
- case 'tag':
123
- for (const tag of revalidationTags) {
124
- revalidateTagCompat(tag);
125
- }
126
- break;
149
+ try {
150
+ switch (revalidationStrategy) {
151
+ case 'path':
152
+ revalidatePath(pathname);
153
+ break;
154
+ case 'tag':
155
+ for (const tag of revalidationTags) {
156
+ revalidateTagCompat(tag);
157
+ }
158
+ break;
159
+ }
160
+ } catch {
161
+ // revalidatePath/revalidateTag may not be available in non-Next.js environments (e.g., vinext)
127
162
  }
128
163
  if (revalidationStrategy !== 'none') {
129
164
  redirect(pathname);
@@ -2,6 +2,7 @@ import { getWorkOS } from './workos.js';
2
2
  import { handleAuth } from './authkit-callback-route.js';
3
3
  import { getSessionFromCookie, saveSession } from './session.js';
4
4
  import { NextRequest, NextResponse } from 'next/server';
5
+ import { sealData } from 'iron-session';
5
6
 
6
7
  // Mocked in vitest.setup.ts
7
8
  import { cookies, headers } from 'next/headers';
@@ -13,6 +14,9 @@ const { fakeWorkosInstance } = vi.hoisted(() => ({
13
14
  authenticateWithCode: vi.fn(),
14
15
  getJwksUrl: vi.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
15
16
  },
17
+ pkce: {
18
+ generate: vi.fn(),
19
+ },
16
20
  },
17
21
  }));
18
22
 
@@ -361,5 +365,86 @@ describe('authkit-callback-route', () => {
361
365
  // Should still redirect correctly
362
366
  expect(response.headers.get('Location')).toContain('/old-path');
363
367
  });
368
+
369
+ describe('PKCE', () => {
370
+ it('should pass codeVerifier from cookie to authenticateWithCode', async () => {
371
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
372
+
373
+ // Seal a verifier into a cookie value
374
+ const sealedVerifier = await sealData(
375
+ { codeVerifier: 'test-verifier-123' },
376
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
377
+ );
378
+
379
+ // Set the PKCE cookie on the request
380
+ request.cookies.set('wos-pkce-verifier', sealedVerifier);
381
+ request.nextUrl.searchParams.set('code', 'test-code');
382
+
383
+ const handler = handleAuth();
384
+ await handler(request);
385
+
386
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
387
+ expect.objectContaining({
388
+ code: 'test-code',
389
+ codeVerifier: 'test-verifier-123',
390
+ }),
391
+ );
392
+ });
393
+
394
+ it('should proceed without codeVerifier when PKCE cookie is missing', async () => {
395
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
396
+
397
+ request.nextUrl.searchParams.set('code', 'test-code');
398
+
399
+ const handler = handleAuth();
400
+ await handler(request);
401
+
402
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
403
+ expect.objectContaining({
404
+ code: 'test-code',
405
+ codeVerifier: undefined,
406
+ }),
407
+ );
408
+ });
409
+
410
+ it('should proceed without codeVerifier when PKCE cookie is corrupted', async () => {
411
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
412
+
413
+ // Set a corrupted cookie
414
+ request.cookies.set('wos-pkce-verifier', 'not-a-valid-sealed-value');
415
+ request.nextUrl.searchParams.set('code', 'test-code');
416
+
417
+ const handler = handleAuth();
418
+ await handler(request);
419
+
420
+ expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
421
+ expect.objectContaining({
422
+ code: 'test-code',
423
+ codeVerifier: undefined,
424
+ }),
425
+ );
426
+ });
427
+
428
+ it('should delete PKCE cookie after successful authentication', async () => {
429
+ vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
430
+
431
+ const sealedVerifier = await sealData(
432
+ { codeVerifier: 'test-verifier-123' },
433
+ { password: process.env.WORKOS_COOKIE_PASSWORD! },
434
+ );
435
+
436
+ request.cookies.set('wos-pkce-verifier', sealedVerifier);
437
+ request.nextUrl.searchParams.set('code', 'test-code');
438
+
439
+ const handler = handleAuth();
440
+ const response = await handler(request);
441
+
442
+ // The response should have a Set-Cookie header to delete the PKCE cookie
443
+ const setCookieHeaders = response.headers.getSetCookie();
444
+ const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith('wos-pkce-verifier='));
445
+ expect(pkceDeletionCookie).toBeDefined();
446
+ expect(pkceDeletionCookie).toContain('Max-Age=0');
447
+ });
448
+ });
364
449
  });
365
450
  });
@@ -1,6 +1,8 @@
1
1
  import { NextRequest } from 'next/server';
2
+ import { getCookieOptions } from './cookie.js';
2
3
  import { WORKOS_CLIENT_ID } from './env-variables.js';
3
4
  import { HandleAuthOptions } from './interfaces.js';
5
+ import { PKCE_COOKIE_NAME, getPKCECodeVerifier } from './pkce.js';
4
6
  import { saveSession } from './session.js';
5
7
  import { errorResponseWithFallback, redirectWithFallback, setCachePreventionHeaders } from './utils.js';
6
8
  import { getWorkOS } from './workos.js';
@@ -54,24 +56,30 @@ export function handleAuth(options: HandleAuthOptions = {}) {
54
56
  }
55
57
 
56
58
  return async function GET(request: NextRequest) {
57
- const code = request.nextUrl.searchParams.get('code');
58
- const state = request.nextUrl.searchParams.get('state');
59
+ // Fall back to standard URL parsing when nextUrl is not available (e.g., vinext)
60
+ const requestUrl = request.nextUrl ?? new URL(request.url);
61
+ const code = requestUrl.searchParams.get('code');
62
+ const state = requestUrl.searchParams.get('state');
59
63
 
60
64
  const { state: customState, returnPathname: returnPathnameState } = handleState(state);
61
65
 
62
66
  if (code) {
63
67
  try {
68
+ const pkceCookie = request.cookies.get(PKCE_COOKIE_NAME);
69
+ const codeVerifier = await getPKCECodeVerifier(pkceCookie?.value);
70
+
64
71
  // Use the code returned to us by AuthKit and authenticate the user with WorkOS
65
72
  const { accessToken, refreshToken, user, impersonator, oauthTokens, authenticationMethod, organizationId } =
66
73
  await getWorkOS().userManagement.authenticateWithCode({
67
74
  clientId: WORKOS_CLIENT_ID,
68
75
  code,
76
+ codeVerifier,
69
77
  });
70
78
 
71
79
  // If baseURL is provided, use it instead of request.nextUrl
72
80
  // This is useful if the app is being run in a container like docker where
73
81
  // the hostname can be different from the one in the request
74
- const url = baseURL ? new URL(baseURL) : request.nextUrl.clone();
82
+ const url = baseURL ? new URL(baseURL) : new URL(requestUrl.toString());
75
83
 
76
84
  // Cleanup params
77
85
  url.searchParams.delete('code');
@@ -90,6 +98,10 @@ export function handleAuth(options: HandleAuthOptions = {}) {
90
98
  const response = redirectWithFallback(url.toString());
91
99
  preventCaching(response.headers);
92
100
 
101
+ if (pkceCookie) {
102
+ response.headers.append('Set-Cookie', `${PKCE_COOKIE_NAME}=; ${getCookieOptions(request.url, true, true)}`);
103
+ }
104
+
93
105
  if (!accessToken || !refreshToken) throw new Error('response is missing tokens');
94
106
 
95
107
  await saveSession({ accessToken, refreshToken, user, impersonator }, request);
@@ -71,7 +71,7 @@ export const AuthKitProvider = ({ children, onSessionExpired, initialAuth }: Aut
71
71
  setEntitlements(auth.entitlements);
72
72
  setFeatureFlags(auth.featureFlags);
73
73
  setImpersonator(auth.impersonator);
74
- } catch (error) {
74
+ } catch {
75
75
  setUser(null);
76
76
  setSessionId(undefined);
77
77
  setOrganizationId(undefined);
@@ -530,7 +530,7 @@ describe('tokenStore', () => {
530
530
 
531
531
  try {
532
532
  await tokenStore.refreshToken();
533
- } catch (e) {
533
+ } catch {
534
534
  // Expected to throw
535
535
  }
536
536
 
@@ -547,7 +547,7 @@ describe('tokenStore', () => {
547
547
 
548
548
  try {
549
549
  await tokenStore.refreshToken();
550
- } catch (e) {
550
+ } catch {
551
551
  // Expected to throw
552
552
  }
553
553
 
@@ -699,7 +699,7 @@ describe('tokenStore', () => {
699
699
 
700
700
  try {
701
701
  await tokenStore.refreshToken();
702
- } catch (e) {
702
+ } catch {
703
703
  // Expected to throw
704
704
  }
705
705
 
@@ -12,6 +12,7 @@ const WORKOS_COOKIE_DOMAIN = getEnvVariable('WORKOS_COOKIE_DOMAIN');
12
12
  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
+ const WORKOS_DISABLE_PKCE = getEnvVariable('WORKOS_DISABLE_PKCE');
15
16
 
16
17
  // Required env variables
17
18
  const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? '';
@@ -31,4 +32,5 @@ export {
31
32
  WORKOS_COOKIE_PASSWORD,
32
33
  WORKOS_REDIRECT_URI,
33
34
  WORKOS_COOKIE_SAMESITE,
35
+ WORKOS_DISABLE_PKCE,
34
36
  };
@@ -8,6 +8,13 @@ const { fakeWorkosInstance } = vi.hoisted(() => ({
8
8
  userManagement: {
9
9
  getAuthorizationUrl: vi.fn(),
10
10
  },
11
+ pkce: {
12
+ generate: vi.fn().mockResolvedValue({
13
+ codeVerifier: 'test-code-verifier',
14
+ codeChallenge: 'test-code-challenge',
15
+ codeChallengeMethod: 'S256' as const,
16
+ }),
17
+ },
11
18
  },
12
19
  }));
13
20
 
@@ -19,6 +26,12 @@ describe('getAuthorizationUrl', () => {
19
26
  const workos = getWorkOS();
20
27
  beforeEach(() => {
21
28
  vi.clearAllMocks();
29
+ delete process.env.WORKOS_DISABLE_PKCE;
30
+ fakeWorkosInstance.pkce.generate.mockResolvedValue({
31
+ codeVerifier: 'test-code-verifier',
32
+ codeChallenge: 'test-code-challenge',
33
+ codeChallengeMethod: 'S256' as const,
34
+ });
22
35
  });
23
36
 
24
37
  it('uses x-redirect-uri header when redirectUri option is not provided', async () => {
@@ -56,4 +69,53 @@ describe('getAuthorizationUrl', () => {
56
69
  }),
57
70
  );
58
71
  });
72
+
73
+ describe('PKCE', () => {
74
+ it('generates PKCE pair and includes codeChallenge in authorization URL', async () => {
75
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
76
+
77
+ const result = await getAuthorizationUrl({});
78
+
79
+ expect(fakeWorkosInstance.pkce.generate).toHaveBeenCalled();
80
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
81
+ expect.objectContaining({
82
+ codeChallenge: 'test-code-challenge',
83
+ codeChallengeMethod: 'S256',
84
+ }),
85
+ );
86
+ expect(result.url).toBe('mock-url');
87
+ expect(result.pkceCookieValue).toBeDefined();
88
+ expect(result.pkceCookieValue).not.toBe('');
89
+ });
90
+
91
+ it('returns sealed cookie value for the verifier', async () => {
92
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
93
+
94
+ const result = await getAuthorizationUrl({});
95
+
96
+ // pkceCookieValue should be a sealed (encrypted) string
97
+ expect(typeof result.pkceCookieValue).toBe('string');
98
+ expect(result.pkceCookieValue!.length).toBeGreaterThan(0);
99
+ });
100
+
101
+ it('skips PKCE when WORKOS_DISABLE_PKCE is set to true', async () => {
102
+ process.env.WORKOS_DISABLE_PKCE = 'true';
103
+
104
+ // Re-import to pick up the new env var
105
+ vi.resetModules();
106
+ const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
107
+
108
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
109
+
110
+ const result = await freshGetAuthorizationUrl({});
111
+
112
+ expect(fakeWorkosInstance.pkce.generate).not.toHaveBeenCalled();
113
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
114
+ expect.not.objectContaining({
115
+ codeChallenge: expect.any(String),
116
+ }),
117
+ );
118
+ expect(result.pkceCookieValue).toBeUndefined();
119
+ });
120
+ });
59
121
  });
@@ -1,9 +1,10 @@
1
- import { getWorkOS } from './workos.js';
2
- import { WORKOS_CLIENT_ID, WORKOS_REDIRECT_URI } from './env-variables.js';
3
- import { GetAuthURLOptions } from './interfaces.js';
1
+ import { sealData } from 'iron-session';
4
2
  import { headers } from 'next/headers';
3
+ import { WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_DISABLE_PKCE, WORKOS_REDIRECT_URI } from './env-variables.js';
4
+ import { GetAuthURLOptions, GetAuthURLResult } from './interfaces.js';
5
+ import { getWorkOS } from './workos.js';
5
6
 
6
- async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
7
+ async function getAuthorizationUrl(options: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
7
8
  const { returnPathname, screenHint, organizationId, loginHint, prompt, state: customState } = options;
8
9
  let redirectUri = options.redirectUri;
9
10
  if (!redirectUri) {
@@ -18,8 +19,8 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
18
19
  const finalState =
19
20
  internalState && customState ? `${internalState}.${customState}` : internalState || customState || undefined;
20
21
 
21
- return getWorkOS().userManagement.getAuthorizationUrl({
22
- provider: 'authkit',
22
+ const baseOptions = {
23
+ provider: 'authkit' as const,
23
24
  clientId: WORKOS_CLIENT_ID,
24
25
  redirectUri: redirectUri ?? WORKOS_REDIRECT_URI,
25
26
  state: finalState,
@@ -27,7 +28,24 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
27
28
  organizationId,
28
29
  loginHint,
29
30
  prompt,
31
+ };
32
+
33
+ if (WORKOS_DISABLE_PKCE === 'true') {
34
+ return { url: getWorkOS().userManagement.getAuthorizationUrl(baseOptions), pkceCookieValue: undefined };
35
+ }
36
+
37
+ const pkce = await getWorkOS().pkce.generate();
38
+ const pkceCookieValue = await sealData(
39
+ { codeVerifier: pkce.codeVerifier },
40
+ { password: WORKOS_COOKIE_PASSWORD, ttl: 600 },
41
+ );
42
+ const url = getWorkOS().userManagement.getAuthorizationUrl({
43
+ ...baseOptions,
44
+ codeChallenge: pkce.codeChallenge,
45
+ codeChallengeMethod: pkce.codeChallengeMethod,
30
46
  });
47
+
48
+ return { url, pkceCookieValue };
31
49
  }
32
50
 
33
51
  export { getAuthorizationUrl };
package/src/interfaces.ts CHANGED
@@ -61,6 +61,11 @@ export interface AccessToken {
61
61
  feature_flags?: string[];
62
62
  }
63
63
 
64
+ export interface GetAuthURLResult {
65
+ url: string;
66
+ pkceCookieValue?: string;
67
+ }
68
+
64
69
  export interface GetAuthURLOptions {
65
70
  screenHint?: 'sign-up' | 'sign-in';
66
71
  returnPathname?: string;
package/src/pkce.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { unsealData } from 'iron-session';
2
+ import { cookies } from 'next/headers';
3
+ import { getCookieOptions } from './cookie.js';
4
+ import { WORKOS_COOKIE_PASSWORD } from './env-variables.js';
5
+
6
+ export const PKCE_COOKIE_NAME = 'wos-pkce-verifier';
7
+ const PKCE_COOKIE_MAX_AGE = 600; // 10 minutes
8
+
9
+ /**
10
+ * Set the PKCE verifier cookie in server action context.
11
+ * In middleware context, callers must set the cookie via Set-Cookie headers instead.
12
+ */
13
+ export async function setPKCECookie(pkceCookieValue: string | undefined): Promise<void> {
14
+ if (!pkceCookieValue) return;
15
+ const nextCookies = await cookies();
16
+ const { domain, path, sameSite, secure } = getCookieOptions();
17
+ nextCookies.set(PKCE_COOKIE_NAME, pkceCookieValue, {
18
+ domain,
19
+ path,
20
+ sameSite,
21
+ secure,
22
+ httpOnly: true,
23
+ maxAge: PKCE_COOKIE_MAX_AGE,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Read and unseal the PKCE code verifier from the cookie.
29
+ * Returns undefined if the cookie is missing or corrupted.
30
+ */
31
+ export async function getPKCECodeVerifier(cookieValue: string | undefined): Promise<string | undefined> {
32
+ if (!cookieValue) return undefined;
33
+ try {
34
+ const unsealed = await unsealData<{ codeVerifier: string }>(cookieValue, {
35
+ password: WORKOS_COOKIE_PASSWORD,
36
+ });
37
+ return unsealed.codeVerifier;
38
+ } catch {
39
+ // Cookie corrupted or expired — caller will proceed without PKCE
40
+ return undefined;
41
+ }
42
+ }
package/src/session.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  Session,
19
19
  UserInfo,
20
20
  } from './interfaces.js';
21
+ import { PKCE_COOKIE_NAME, setPKCECookie } from './pkce.js';
21
22
  import { getWorkOS } from './workos.js';
22
23
 
23
24
  import type { AuthenticationResponse } from '@workos-inc/node';
@@ -25,6 +26,12 @@ import { parse, tokensToRegexp } from 'path-to-regexp';
25
26
  import { handleAuthkitHeaders } from './middleware-helpers.js';
26
27
  import { lazy, setCachePreventionHeaders } from './utils.js';
27
28
 
29
+ function appendPKCESetCookieHeader(headers: Headers, pkceCookieValue: string | undefined, requestUrl: string): void {
30
+ if (pkceCookieValue) {
31
+ headers.append('Set-Cookie', `${PKCE_COOKIE_NAME}=${pkceCookieValue}; ${getCookieOptions(requestUrl, true)}`);
32
+ }
33
+ }
34
+
28
35
  const sessionHeaderName = 'x-workos-session';
29
36
  const middlewareHeaderName = 'x-workos-middleware';
30
37
  const signUpPathsHeaderName = 'x-sign-up-paths';
@@ -202,14 +209,18 @@ async function updateSession(
202
209
  console.log('No session found from cookie');
203
210
  }
204
211
 
212
+ const { url: authorizationUrl, pkceCookieValue } = await getAuthorizationUrl({
213
+ returnPathname: getReturnPathname(request.url),
214
+ redirectUri: options.redirectUri || WORKOS_REDIRECT_URI,
215
+ screenHint: options.screenHint,
216
+ });
217
+
218
+ appendPKCESetCookieHeader(newRequestHeaders, pkceCookieValue, request.url);
219
+
205
220
  return {
206
221
  session: { user: null },
207
222
  headers: newRequestHeaders,
208
- authorizationUrl: await getAuthorizationUrl({
209
- returnPathname: getReturnPathname(request.url),
210
- redirectUri: options.redirectUri || WORKOS_REDIRECT_URI,
211
- screenHint: options.screenHint,
212
- }),
223
+ authorizationUrl,
213
224
  };
214
225
  }
215
226
 
@@ -340,13 +351,17 @@ async function updateSession(
340
351
 
341
352
  options.onSessionRefreshError?.({ error: e, request });
342
353
 
354
+ const { url: authorizationUrl, pkceCookieValue } = await getAuthorizationUrl({
355
+ returnPathname: getReturnPathname(request.url),
356
+ redirectUri: options.redirectUri || WORKOS_REDIRECT_URI,
357
+ });
358
+
359
+ appendPKCESetCookieHeader(newRequestHeaders, pkceCookieValue, request.url);
360
+
343
361
  return {
344
362
  session: { user: null },
345
363
  headers: newRequestHeaders,
346
- authorizationUrl: await getAuthorizationUrl({
347
- returnPathname: getReturnPathname(request.url),
348
- redirectUri: options.redirectUri || WORKOS_REDIRECT_URI,
349
- }),
364
+ authorizationUrl,
350
365
  };
351
366
  }
352
367
  }
@@ -453,7 +468,9 @@ async function redirectToSignIn() {
453
468
 
454
469
  const returnPathname = getReturnPathname(url);
455
470
 
456
- redirect(await getAuthorizationUrl({ returnPathname, screenHint }));
471
+ const { url: authkitUrl, pkceCookieValue } = await getAuthorizationUrl({ returnPathname, screenHint });
472
+ await setPKCECookie(pkceCookieValue);
473
+ redirect(authkitUrl);
457
474
  }
458
475
 
459
476
  export async function getTokenClaims<T = Record<string, unknown>>(