@workos-inc/authkit-nextjs 3.0.0-beta.1 → 3.0.1
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 +305 -102
- package/dist/esm/actions.js +35 -5
- package/dist/esm/actions.js.map +1 -1
- package/dist/esm/auth.js +71 -21
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +90 -92
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/authkit-provider.js +36 -15
- package/dist/esm/components/authkit-provider.js.map +1 -1
- package/dist/esm/components/impersonation.js +17 -15
- package/dist/esm/components/impersonation.js.map +1 -1
- package/dist/esm/components/min-max-button.js +1 -1
- package/dist/esm/components/min-max-button.js.map +1 -1
- package/dist/esm/components/tokenStore.js +28 -19
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +1 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/components/useTokenClaims.js +1 -1
- package/dist/esm/components/useTokenClaims.js.map +1 -1
- package/dist/esm/cookie.js +20 -5
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/env-variables.js +6 -6
- package/dist/esm/env-variables.js.map +1 -1
- package/dist/esm/errors.js +36 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/get-authorization-url.js +51 -12
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/index.js +5 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interfaces.js +7 -1
- package/dist/esm/interfaces.js.map +1 -1
- package/dist/esm/middleware-helpers.js +102 -0
- package/dist/esm/middleware-helpers.js.map +1 -0
- package/dist/esm/middleware.js +3 -1
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/pkce.js +52 -0
- package/dist/esm/pkce.js.map +1 -0
- package/dist/esm/session.js +82 -35
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +1 -1
- package/dist/esm/test-helpers.js.map +1 -1
- package/dist/esm/types/actions.d.ts +34 -5
- package/dist/esm/types/auth.d.ts +7 -15
- package/dist/esm/types/components/authkit-provider.d.ts +6 -2
- package/dist/esm/types/components/impersonation.d.ts +2 -1
- package/dist/esm/types/cookie.d.ts +9 -0
- package/dist/esm/types/env-variables.d.ts +2 -1
- package/dist/esm/types/errors.d.ts +15 -0
- package/dist/esm/types/get-authorization-url.d.ts +2 -2
- package/dist/esm/types/index.d.ts +5 -2
- package/dist/esm/types/interfaces.d.ts +12 -0
- package/dist/esm/types/jwt.d.ts +9 -9
- package/dist/esm/types/middleware-helpers.d.ts +27 -0
- package/dist/esm/types/middleware.d.ts +3 -1
- package/dist/esm/types/pkce.d.ts +17 -0
- package/dist/esm/types/session.d.ts +1 -1
- package/dist/esm/types/utils.d.ts +5 -0
- package/dist/esm/types/validate-api-key.d.ts +1 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/utils.js +10 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/validate-api-key.js +16 -0
- package/dist/esm/validate-api-key.js.map +1 -0
- package/dist/esm/workos.js +1 -1
- package/package.json +33 -34
- package/src/actions.spec.ts +91 -18
- package/src/actions.ts +44 -6
- package/src/auth.spec.ts +79 -29
- package/src/auth.ts +74 -42
- package/src/authkit-callback-route.spec.ts +372 -58
- package/src/authkit-callback-route.ts +121 -103
- package/src/components/authkit-provider.spec.tsx +264 -70
- package/src/components/authkit-provider.tsx +40 -15
- package/src/components/button.spec.tsx +4 -6
- package/src/components/impersonation.spec.tsx +152 -35
- package/src/components/impersonation.tsx +37 -30
- package/src/components/min-max-button.spec.tsx +2 -1
- package/src/components/tokenStore.spec.ts +59 -44
- package/src/components/tokenStore.ts +11 -3
- package/src/components/useAccessToken.spec.tsx +82 -83
- package/src/components/useTokenClaims.spec.tsx +23 -22
- package/src/cookie.spec.ts +63 -9
- package/src/cookie.ts +35 -0
- package/src/env-variables.ts +2 -0
- package/src/errors.spec.ts +108 -0
- package/src/errors.ts +46 -0
- package/src/get-authorization-url.spec.ts +170 -15
- package/src/get-authorization-url.ts +69 -23
- package/src/index.ts +20 -2
- package/src/interfaces.ts +15 -0
- package/src/jwt.ts +9 -9
- package/src/middleware-helpers.spec.ts +238 -0
- package/src/middleware-helpers.ts +134 -0
- package/src/middleware.spec.ts +25 -0
- package/src/middleware.ts +4 -1
- package/src/pkce.spec.ts +146 -0
- package/src/pkce.ts +59 -0
- package/src/session.spec.ts +87 -89
- package/src/session.ts +104 -27
- package/src/test-helpers.ts +1 -1
- package/src/utils.spec.ts +14 -31
- package/src/utils.ts +9 -0
- package/src/validate-api-key.spec.ts +111 -0
- package/src/validate-api-key.ts +19 -0
- package/src/workos.spec.ts +2 -2
- package/src/workos.ts +1 -1
|
@@ -1,21 +1,30 @@
|
|
|
1
|
+
import type { Mock } from 'vitest';
|
|
1
2
|
import { getWorkOS } from './workos.js';
|
|
2
3
|
import { handleAuth } from './authkit-callback-route.js';
|
|
4
|
+
import { getPKCECookieNameForState } from './pkce.js';
|
|
3
5
|
import { getSessionFromCookie, saveSession } from './session.js';
|
|
4
6
|
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { sealData } from 'iron-session';
|
|
5
8
|
|
|
6
|
-
// Mocked in
|
|
9
|
+
// Mocked in vitest.setup.ts
|
|
7
10
|
import { cookies, headers } from 'next/headers';
|
|
11
|
+
import { State } from './interfaces.js';
|
|
8
12
|
|
|
9
13
|
// Mock dependencies
|
|
10
|
-
const fakeWorkosInstance = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
const { fakeWorkosInstance } = vi.hoisted(() => ({
|
|
15
|
+
fakeWorkosInstance: {
|
|
16
|
+
userManagement: {
|
|
17
|
+
authenticateWithCode: vi.fn(),
|
|
18
|
+
getJwksUrl: vi.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
|
|
19
|
+
},
|
|
20
|
+
pkce: {
|
|
21
|
+
generate: vi.fn(),
|
|
22
|
+
},
|
|
14
23
|
},
|
|
15
|
-
};
|
|
24
|
+
}));
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
getWorkOS:
|
|
26
|
+
vi.mock('../src/workos', () => ({
|
|
27
|
+
getWorkOS: vi.fn(() => fakeWorkosInstance),
|
|
19
28
|
}));
|
|
20
29
|
|
|
21
30
|
describe('authkit-callback-route', () => {
|
|
@@ -34,9 +43,9 @@ describe('authkit-callback-route', () => {
|
|
|
34
43
|
createdAt: '2024-01-01T00:00:00Z',
|
|
35
44
|
updatedAt: '2024-01-01T00:00:00Z',
|
|
36
45
|
lastSignInAt: '2024-01-01T00:00:00Z',
|
|
37
|
-
locale: null,
|
|
38
46
|
externalId: null,
|
|
39
47
|
metadata: {},
|
|
48
|
+
locale: null,
|
|
40
49
|
},
|
|
41
50
|
oauthTokens: {
|
|
42
51
|
accessToken: 'access123',
|
|
@@ -46,17 +55,23 @@ describe('authkit-callback-route', () => {
|
|
|
46
55
|
},
|
|
47
56
|
};
|
|
48
57
|
|
|
58
|
+
async function setAuthCookie(req: NextRequest, state: State): Promise<string> {
|
|
59
|
+
const sealedState = await sealData(state, { password: process.env.WORKOS_COOKIE_PASSWORD! });
|
|
60
|
+
req.cookies.set(getPKCECookieNameForState(sealedState), sealedState);
|
|
61
|
+
return sealedState;
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
describe('handleAuth', () => {
|
|
50
65
|
let request: NextRequest;
|
|
51
66
|
|
|
52
67
|
beforeAll(() => {
|
|
53
68
|
// Silence console.error during tests
|
|
54
|
-
|
|
69
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
55
70
|
});
|
|
56
71
|
|
|
57
72
|
beforeEach(async () => {
|
|
58
73
|
// Reset all mocks
|
|
59
|
-
|
|
74
|
+
vi.clearAllMocks();
|
|
60
75
|
|
|
61
76
|
// Create a new request with searchParams
|
|
62
77
|
request = new NextRequest(new URL('http://example.com/callback'));
|
|
@@ -72,10 +87,12 @@ describe('authkit-callback-route', () => {
|
|
|
72
87
|
});
|
|
73
88
|
|
|
74
89
|
it('should handle successful authentication', async () => {
|
|
75
|
-
|
|
90
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
76
91
|
|
|
77
|
-
// Set up request with code
|
|
92
|
+
// Set up request with code & state
|
|
93
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
78
94
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
95
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
79
96
|
|
|
80
97
|
const handler = handleAuth();
|
|
81
98
|
const response = await handler(request);
|
|
@@ -83,15 +100,17 @@ describe('authkit-callback-route', () => {
|
|
|
83
100
|
expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({
|
|
84
101
|
clientId: process.env.WORKOS_CLIENT_ID,
|
|
85
102
|
code: 'test-code',
|
|
103
|
+
codeVerifier: 'test-verifier',
|
|
86
104
|
});
|
|
87
105
|
expect(response).toBeInstanceOf(NextResponse);
|
|
88
106
|
});
|
|
89
107
|
|
|
90
108
|
it('should handle authentication failure', async () => {
|
|
91
|
-
|
|
92
|
-
(workos.userManagement.authenticateWithCode as jest.Mock).mockRejectedValue(new Error('Auth failed'));
|
|
109
|
+
(workos.userManagement.authenticateWithCode as Mock).mockRejectedValue(new Error('Auth failed'));
|
|
93
110
|
|
|
111
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
94
112
|
request.nextUrl.searchParams.set('code', 'invalid-code');
|
|
113
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
95
114
|
|
|
96
115
|
const handler = handleAuth();
|
|
97
116
|
const response = await handler(request);
|
|
@@ -102,10 +121,11 @@ describe('authkit-callback-route', () => {
|
|
|
102
121
|
});
|
|
103
122
|
|
|
104
123
|
it('should handle authentication failure if a non-Error object is thrown', async () => {
|
|
105
|
-
|
|
106
|
-
jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
|
|
124
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
|
|
107
125
|
|
|
126
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
108
127
|
request.nextUrl.searchParams.set('code', 'invalid-code');
|
|
128
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
109
129
|
|
|
110
130
|
const handler = handleAuth();
|
|
111
131
|
const response = await handler(request);
|
|
@@ -116,9 +136,11 @@ describe('authkit-callback-route', () => {
|
|
|
116
136
|
});
|
|
117
137
|
|
|
118
138
|
it('should handle authentication failure with custom onError handler', async () => {
|
|
119
|
-
|
|
120
|
-
|
|
139
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
|
|
140
|
+
|
|
141
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
121
142
|
request.nextUrl.searchParams.set('code', 'invalid-code');
|
|
143
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
122
144
|
|
|
123
145
|
const handler = handleAuth({
|
|
124
146
|
onError: () => {
|
|
@@ -145,9 +167,11 @@ describe('authkit-callback-route', () => {
|
|
|
145
167
|
});
|
|
146
168
|
|
|
147
169
|
it('should respect custom returnPathname', async () => {
|
|
148
|
-
|
|
170
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
149
171
|
|
|
172
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
150
173
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
174
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
151
175
|
|
|
152
176
|
const handler = handleAuth({ returnPathname: '/dashboard' });
|
|
153
177
|
const response = await handler(request);
|
|
@@ -156,11 +180,15 @@ describe('authkit-callback-route', () => {
|
|
|
156
180
|
});
|
|
157
181
|
|
|
158
182
|
it('should handle state parameter with returnPathname', async () => {
|
|
159
|
-
|
|
183
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
160
184
|
|
|
161
|
-
const
|
|
185
|
+
const sealedState = await setAuthCookie(request, {
|
|
186
|
+
nonce: 'foo',
|
|
187
|
+
codeVerifier: 'test-verifier',
|
|
188
|
+
returnPathname: '/custom-path',
|
|
189
|
+
});
|
|
162
190
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
163
|
-
request.nextUrl.searchParams.set('state',
|
|
191
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
164
192
|
|
|
165
193
|
const handler = handleAuth();
|
|
166
194
|
const response = await handler(request);
|
|
@@ -169,11 +197,15 @@ describe('authkit-callback-route', () => {
|
|
|
169
197
|
});
|
|
170
198
|
|
|
171
199
|
it('should extract custom search params from returnPathname', async () => {
|
|
172
|
-
|
|
200
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
173
201
|
|
|
174
|
-
const
|
|
202
|
+
const sealedState = await setAuthCookie(request, {
|
|
203
|
+
nonce: 'foo',
|
|
204
|
+
codeVerifier: 'test-verifier',
|
|
205
|
+
returnPathname: '/custom-path?foo=bar&baz=qux',
|
|
206
|
+
});
|
|
175
207
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
176
|
-
request.nextUrl.searchParams.set('state',
|
|
208
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
177
209
|
|
|
178
210
|
const handler = handleAuth();
|
|
179
211
|
const response = await handler(request);
|
|
@@ -181,14 +213,35 @@ describe('authkit-callback-route', () => {
|
|
|
181
213
|
expect(response.headers.get('Location')).toContain('/custom-path?foo=bar&baz=qux');
|
|
182
214
|
});
|
|
183
215
|
|
|
216
|
+
it('should handle full URL in returnPathname by extracting only the pathname', async () => {
|
|
217
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
218
|
+
|
|
219
|
+
const sealedState = await setAuthCookie(request, {
|
|
220
|
+
nonce: 'foo',
|
|
221
|
+
codeVerifier: 'test-verifier',
|
|
222
|
+
returnPathname: 'https://example.com/invite/k0123456789',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
226
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
227
|
+
|
|
228
|
+
const handler = handleAuth();
|
|
229
|
+
const response = await handler(request);
|
|
230
|
+
|
|
231
|
+
const location = response.headers.get('Location');
|
|
232
|
+
expect(location).toContain('/invite/k0123456789');
|
|
233
|
+
expect(location).not.toContain('https://example.com/invite');
|
|
234
|
+
});
|
|
235
|
+
|
|
184
236
|
it('should use Response if NextResponse.redirect is not available', async () => {
|
|
185
237
|
const originalRedirect = NextResponse.redirect;
|
|
186
238
|
(NextResponse as Partial<typeof NextResponse>).redirect = undefined;
|
|
187
239
|
|
|
188
|
-
|
|
240
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
189
241
|
|
|
190
|
-
|
|
242
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
191
243
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
244
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
192
245
|
|
|
193
246
|
const handler = handleAuth();
|
|
194
247
|
const response = await handler(request);
|
|
@@ -203,6 +256,10 @@ describe('authkit-callback-route', () => {
|
|
|
203
256
|
const originalJson = NextResponse.json;
|
|
204
257
|
(NextResponse as Partial<typeof NextResponse>).json = undefined;
|
|
205
258
|
|
|
259
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
260
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
261
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
262
|
+
|
|
206
263
|
const handler = handleAuth();
|
|
207
264
|
const response = await handler(request);
|
|
208
265
|
|
|
@@ -217,10 +274,12 @@ describe('authkit-callback-route', () => {
|
|
|
217
274
|
});
|
|
218
275
|
|
|
219
276
|
it('should use baseURL if provided', async () => {
|
|
220
|
-
|
|
277
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
221
278
|
|
|
222
|
-
// Set up request with code
|
|
279
|
+
// Set up request with code & state
|
|
280
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
223
281
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
282
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
224
283
|
|
|
225
284
|
const handler = handleAuth({ baseURL: 'https://base.com' });
|
|
226
285
|
const response = await handler(request);
|
|
@@ -229,14 +288,15 @@ describe('authkit-callback-route', () => {
|
|
|
229
288
|
});
|
|
230
289
|
|
|
231
290
|
it('should throw an error if response is missing tokens', async () => {
|
|
232
|
-
const
|
|
291
|
+
const incompleteAuthResponse = {
|
|
233
292
|
user: { id: 'user_123' },
|
|
234
293
|
};
|
|
235
294
|
|
|
236
|
-
(workos.userManagement.authenticateWithCode as
|
|
295
|
+
(workos.userManagement.authenticateWithCode as Mock).mockResolvedValue(incompleteAuthResponse);
|
|
237
296
|
|
|
238
|
-
|
|
297
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
239
298
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
299
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
240
300
|
|
|
241
301
|
const handler = handleAuth();
|
|
242
302
|
const response = await handler(request);
|
|
@@ -245,12 +305,14 @@ describe('authkit-callback-route', () => {
|
|
|
245
305
|
});
|
|
246
306
|
|
|
247
307
|
it('should call onSuccess if provided', async () => {
|
|
248
|
-
|
|
308
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
249
309
|
|
|
250
|
-
// Set up request with code
|
|
310
|
+
// Set up request with code & state
|
|
311
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
251
312
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
313
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
252
314
|
|
|
253
|
-
const onSuccess =
|
|
315
|
+
const onSuccess = vi.fn();
|
|
254
316
|
const handler = handleAuth({ onSuccess: onSuccess });
|
|
255
317
|
await handler(request);
|
|
256
318
|
|
|
@@ -261,10 +323,11 @@ describe('authkit-callback-route', () => {
|
|
|
261
323
|
|
|
262
324
|
it('should allow onSuccess to update session', async () => {
|
|
263
325
|
const newAccessToken = 'new-access-token';
|
|
264
|
-
|
|
326
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
265
327
|
|
|
266
|
-
|
|
328
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
267
329
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
330
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
268
331
|
|
|
269
332
|
const handler = handleAuth({
|
|
270
333
|
onSuccess: async (data) => {
|
|
@@ -278,19 +341,19 @@ describe('authkit-callback-route', () => {
|
|
|
278
341
|
});
|
|
279
342
|
|
|
280
343
|
it('should pass custom state data to onSuccess callback', async () => {
|
|
281
|
-
|
|
344
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
282
345
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
346
|
+
const sealedState = await setAuthCookie(request, {
|
|
347
|
+
nonce: 'foo',
|
|
348
|
+
codeVerifier: 'test-verifier',
|
|
349
|
+
returnPathname: '/dashboard',
|
|
350
|
+
customState: 'custom-user-state-string',
|
|
351
|
+
});
|
|
289
352
|
|
|
290
353
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
291
|
-
request.nextUrl.searchParams.set('state',
|
|
354
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
292
355
|
|
|
293
|
-
const onSuccess =
|
|
356
|
+
const onSuccess = vi.fn();
|
|
294
357
|
const handler = handleAuth({ onSuccess });
|
|
295
358
|
await handler(request);
|
|
296
359
|
|
|
@@ -308,15 +371,19 @@ describe('authkit-callback-route', () => {
|
|
|
308
371
|
});
|
|
309
372
|
|
|
310
373
|
it('should handle state without custom data', async () => {
|
|
311
|
-
|
|
374
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
312
375
|
|
|
313
376
|
// State with only returnPathname
|
|
314
|
-
const
|
|
377
|
+
const sealedState = await setAuthCookie(request, {
|
|
378
|
+
nonce: 'foo',
|
|
379
|
+
codeVerifier: 'test-verifier',
|
|
380
|
+
returnPathname: '/profile',
|
|
381
|
+
});
|
|
315
382
|
|
|
316
383
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
317
|
-
request.nextUrl.searchParams.set('state',
|
|
384
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
318
385
|
|
|
319
|
-
const onSuccess =
|
|
386
|
+
const onSuccess = vi.fn();
|
|
320
387
|
const handler = handleAuth({ onSuccess });
|
|
321
388
|
await handler(request);
|
|
322
389
|
|
|
@@ -329,20 +396,267 @@ describe('authkit-callback-route', () => {
|
|
|
329
396
|
);
|
|
330
397
|
});
|
|
331
398
|
|
|
332
|
-
it('should handle backward compatibility with old state format', async () => {
|
|
333
|
-
|
|
399
|
+
it('should NOT handle backward compatibility with old state format', async () => {
|
|
400
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
334
401
|
|
|
335
402
|
// Old format: just returnPathname
|
|
336
|
-
|
|
337
|
-
|
|
403
|
+
// @ts-expect-error we're purposely testing backward compatibility with an old format that doesn't match the current State interface
|
|
404
|
+
const sealedState = await setAuthCookie(request, { returnPathname: '/old-path' });
|
|
338
405
|
request.nextUrl.searchParams.set('code', 'test-code');
|
|
339
|
-
request.nextUrl.searchParams.set('state',
|
|
406
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
340
407
|
|
|
341
408
|
const handler = handleAuth();
|
|
342
409
|
const response = await handler(request);
|
|
343
410
|
|
|
344
|
-
// Should
|
|
345
|
-
expect(response.
|
|
411
|
+
// Should error
|
|
412
|
+
expect(response.status).toBe(500);
|
|
413
|
+
expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should not leak nonce-only state as custom state in onSuccess', async () => {
|
|
417
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
418
|
+
|
|
419
|
+
// Simulate a nonce-only state (no returnPathname, no custom state)
|
|
420
|
+
const nonceState = await setAuthCookie(request, { nonce: 'test-nonce', codeVerifier: 'test-verifier' });
|
|
421
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
422
|
+
request.nextUrl.searchParams.set('state', nonceState);
|
|
423
|
+
|
|
424
|
+
const onSuccess = vi.fn();
|
|
425
|
+
const handler = handleAuth({ onSuccess });
|
|
426
|
+
await handler(request);
|
|
427
|
+
|
|
428
|
+
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ state: undefined }));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('state verification', () => {
|
|
432
|
+
it('should reject callback when state does not match stored state', async () => {
|
|
433
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
434
|
+
|
|
435
|
+
const state = 'attacker-state';
|
|
436
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
437
|
+
request.nextUrl.searchParams.set('state', state);
|
|
438
|
+
await setAuthCookie(request, { nonce: 'legitimate-state', codeVerifier: 'test-verifier' });
|
|
439
|
+
|
|
440
|
+
const handler = handleAuth();
|
|
441
|
+
const response = await handler(request);
|
|
442
|
+
|
|
443
|
+
expect(response.status).toBe(500);
|
|
444
|
+
expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should reject when state is present but no cookie exists', async () => {
|
|
448
|
+
const sealedState = await sealData(
|
|
449
|
+
{ nonce: 'foo', codeVerifier: 'test-verifier' },
|
|
450
|
+
{ password: process.env.WORKOS_COOKIE_PASSWORD! },
|
|
451
|
+
);
|
|
452
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
453
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
454
|
+
|
|
455
|
+
const handler = handleAuth();
|
|
456
|
+
const response = await handler(request);
|
|
457
|
+
|
|
458
|
+
expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
|
|
459
|
+
expect(response.status).toBe(500);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should pass when state matches stored state', async () => {
|
|
463
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
464
|
+
|
|
465
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier' });
|
|
466
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
467
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
468
|
+
|
|
469
|
+
const handler = handleAuth();
|
|
470
|
+
const response = await handler(request);
|
|
471
|
+
|
|
472
|
+
expect(workos.userManagement.authenticateWithCode).toHaveBeenCalled();
|
|
473
|
+
expect(response.status).not.toBe(500);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should return 500 when neither state nor cookie exist', async () => {
|
|
477
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
478
|
+
|
|
479
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
480
|
+
|
|
481
|
+
const handler = handleAuth();
|
|
482
|
+
const response = await handler(request);
|
|
483
|
+
|
|
484
|
+
expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
|
|
485
|
+
expect(response.status).toBe(500);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe('PKCE', () => {
|
|
490
|
+
it('should pass codeVerifier and verify state when both are in the cookie', async () => {
|
|
491
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
492
|
+
|
|
493
|
+
const sealedState = await setAuthCookie(request, {
|
|
494
|
+
codeVerifier: 'test-verifier-456',
|
|
495
|
+
returnPathname: '/dashboard',
|
|
496
|
+
nonce: 'foo',
|
|
497
|
+
});
|
|
498
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
499
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
500
|
+
|
|
501
|
+
const handler = handleAuth();
|
|
502
|
+
const response = await handler(request);
|
|
503
|
+
|
|
504
|
+
expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
|
|
505
|
+
expect.objectContaining({
|
|
506
|
+
code: 'test-code',
|
|
507
|
+
codeVerifier: 'test-verifier-456',
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
expect(response.headers.get('Location')).toContain('/dashboard');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('should pass codeVerifier from cookie to authenticateWithCode', async () => {
|
|
514
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
515
|
+
const sealedState = await setAuthCookie(request, {
|
|
516
|
+
nonce: 'foo',
|
|
517
|
+
codeVerifier: 'test-verifier-123',
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
521
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
522
|
+
|
|
523
|
+
const handler = handleAuth();
|
|
524
|
+
await handler(request);
|
|
525
|
+
|
|
526
|
+
expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith(
|
|
527
|
+
expect.objectContaining({
|
|
528
|
+
code: 'test-code',
|
|
529
|
+
codeVerifier: 'test-verifier-123',
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should reject when cookie is missing even if state contains valid sealed data', async () => {
|
|
535
|
+
const sealedState = await sealData(
|
|
536
|
+
{ nonce: 'foo', codeVerifier: 'test-verifier-123' },
|
|
537
|
+
{ password: process.env.WORKOS_COOKIE_PASSWORD! },
|
|
538
|
+
);
|
|
539
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
540
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
541
|
+
|
|
542
|
+
const handler = handleAuth();
|
|
543
|
+
const response = await handler(request);
|
|
544
|
+
|
|
545
|
+
expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
|
|
546
|
+
expect(response.status).toBe(500);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('should return an error response when PKCE cookie is corrupted', async () => {
|
|
550
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
551
|
+
|
|
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);
|
|
555
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
556
|
+
request.nextUrl.searchParams.set('state', corruptedState);
|
|
557
|
+
|
|
558
|
+
const handler = handleAuth();
|
|
559
|
+
const response = await handler(request);
|
|
560
|
+
|
|
561
|
+
expect(response.status).toBe(500);
|
|
562
|
+
expect(workos.userManagement.authenticateWithCode).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('should delete PKCE cookie after successful authentication', async () => {
|
|
566
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
567
|
+
|
|
568
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
|
|
569
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
570
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
571
|
+
|
|
572
|
+
const handler = handleAuth();
|
|
573
|
+
const response = await handler(request);
|
|
574
|
+
|
|
575
|
+
// The response should be a redirect (success) and have a Set-Cookie header to delete the flow-specific PKCE cookie
|
|
576
|
+
expect(response.status).toBe(307);
|
|
577
|
+
|
|
578
|
+
const flowCookieName = getPKCECookieNameForState(sealedState);
|
|
579
|
+
const setCookieHeaders = response.headers.getSetCookie();
|
|
580
|
+
const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith(`${flowCookieName}=`));
|
|
581
|
+
expect(pkceDeletionCookie).toBeDefined();
|
|
582
|
+
expect(pkceDeletionCookie).toContain('Max-Age=0');
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('should delete PKCE cookie after failed authentication', async () => {
|
|
586
|
+
vi.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue(new Error('Auth failed'));
|
|
587
|
+
|
|
588
|
+
const sealedState = await setAuthCookie(request, { nonce: 'foo', codeVerifier: 'test-verifier-123' });
|
|
589
|
+
request.nextUrl.searchParams.set('code', 'bad-code');
|
|
590
|
+
request.nextUrl.searchParams.set('state', sealedState);
|
|
591
|
+
|
|
592
|
+
const handler = handleAuth();
|
|
593
|
+
const response = await handler(request);
|
|
594
|
+
|
|
595
|
+
expect(response.status).toBe(500);
|
|
596
|
+
const flowCookieName = getPKCECookieNameForState(sealedState);
|
|
597
|
+
const setCookieHeaders = response.headers.getSetCookie();
|
|
598
|
+
const pkceDeletionCookie = setCookieHeaders.find((c: string) => c.startsWith(`${flowCookieName}=`));
|
|
599
|
+
expect(pkceDeletionCookie).toBeDefined();
|
|
600
|
+
expect(pkceDeletionCookie).toContain('Max-Age=0');
|
|
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
|
+
});
|
|
346
660
|
});
|
|
347
661
|
});
|
|
348
662
|
});
|