@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/README.md +29 -0
- package/dist/esm/actions.js +3 -4
- package/dist/esm/actions.js.map +1 -1
- package/dist/esm/auth.js +21 -2
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +24 -15
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/cookie.js +5 -1
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/pkce.js +22 -8
- package/dist/esm/pkce.js.map +1 -1
- package/dist/esm/session.js +14 -5
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/types/actions.d.ts +2 -2
- package/dist/esm/types/cookie.d.ts +1 -0
- package/dist/esm/types/pkce.d.ts +5 -0
- package/dist/esm/validate-api-key.js +1 -1
- package/dist/esm/validate-api-key.js.map +1 -1
- package/package.json +6 -2
- package/src/actions.spec.ts +6 -10
- package/src/actions.ts +3 -4
- package/src/auth.spec.ts +19 -0
- package/src/auth.ts +20 -2
- package/src/authkit-callback-route.spec.ts +69 -7
- package/src/authkit-callback-route.ts +32 -17
- package/src/cookie.spec.ts +49 -0
- package/src/cookie.ts +7 -1
- package/src/pkce.spec.ts +26 -5
- package/src/pkce.ts +25 -8
- package/src/session.ts +18 -5
- package/src/validate-api-key.spec.ts +10 -10
- package/src/validate-api-key.ts +1 -1
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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',
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
29
|
-
|
|
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
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/cookie.spec.ts
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
|
38
|
+
const options = getPKCECookieOptions();
|
|
18
39
|
|
|
19
|
-
nextCookies.set(
|
|
20
|
-
|
|
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 {
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
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('
|
|
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, '
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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, '
|
|
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.
|
|
105
|
+
expect(workos.apiKeys.createValidation).toHaveBeenCalledWith({
|
|
106
106
|
value: 'invalid_key',
|
|
107
107
|
});
|
|
108
108
|
expect(result).toEqual({ apiKey: null });
|
package/src/validate-api-key.ts
CHANGED