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