@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/README.md +1 -0
- package/dist/esm/auth.js +48 -16
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +14 -3
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/authkit-provider.js +1 -1
- package/dist/esm/components/authkit-provider.js.map +1 -1
- package/dist/esm/env-variables.js +2 -1
- package/dist/esm/env-variables.js.map +1 -1
- package/dist/esm/get-authorization-url.js +15 -3
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/pkce.js +43 -0
- package/dist/esm/pkce.js.map +1 -0
- package/dist/esm/session.js +22 -10
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/types/auth.d.ts +4 -2
- package/dist/esm/types/env-variables.d.ts +2 -1
- package/dist/esm/types/get-authorization-url.d.ts +2 -2
- package/dist/esm/types/interfaces.d.ts +4 -0
- package/dist/esm/types/pkce.d.ts +11 -0
- package/package.json +16 -16
- package/src/auth.spec.ts +32 -0
- package/src/auth.ts +50 -15
- package/src/authkit-callback-route.spec.ts +85 -0
- package/src/authkit-callback-route.ts +15 -3
- package/src/components/authkit-provider.tsx +1 -1
- package/src/components/tokenStore.spec.ts +3 -3
- package/src/env-variables.ts +2 -0
- package/src/get-authorization-url.spec.ts +62 -0
- package/src/get-authorization-url.ts +24 -6
- package/src/interfaces.ts +5 -0
- package/src/pkce.ts +42 -0
- package/src/session.ts +27 -10
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
return redirect(url);
|
|
143
|
+
return redirect(await getAuthURLAndSetPKCECookie({ organizationId }));
|
|
113
144
|
}
|
|
114
145
|
throw error;
|
|
115
146
|
}
|
|
116
147
|
}
|
|
117
148
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
58
|
-
const
|
|
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) :
|
|
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
|
|
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
|
|
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
|
|
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
|
|
702
|
+
} catch {
|
|
703
703
|
// Expected to throw
|
|
704
704
|
}
|
|
705
705
|
|
package/src/env-variables.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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>>(
|