@workos-inc/authkit-nextjs 2.5.0 → 2.7.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 +124 -29
- package/dist/esm/auth.js +18 -5
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/components/tokenStore.js +110 -11
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +34 -4
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/cookie.js +51 -0
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/get-authorization-url.js +2 -1
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/middleware.js +2 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/session.js +36 -3
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +57 -0
- package/dist/esm/test-helpers.js.map +1 -0
- package/dist/esm/types/auth.d.ts +5 -3
- package/dist/esm/types/components/tokenStore.d.ts +7 -2
- package/dist/esm/types/cookie.d.ts +1 -0
- package/dist/esm/types/interfaces.d.ts +3 -0
- package/dist/esm/types/middleware.d.ts +1 -1
- package/dist/esm/types/session.d.ts +2 -1
- package/dist/esm/types/test-helpers.d.ts +3 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/workos.js +1 -1
- package/package.json +5 -4
- package/src/actions.spec.ts +100 -0
- package/src/auth.spec.ts +347 -0
- package/src/auth.ts +19 -6
- package/src/authkit-callback-route.spec.ts +258 -0
- package/src/components/authkit-provider.spec.tsx +471 -0
- package/src/components/button.spec.tsx +46 -0
- package/src/components/impersonation.spec.tsx +134 -0
- package/src/components/min-max-button.spec.tsx +60 -0
- package/src/components/tokenStore.spec.ts +816 -0
- package/src/components/tokenStore.ts +147 -12
- package/src/components/useAccessToken.spec.tsx +731 -0
- package/src/components/useAccessToken.ts +40 -6
- package/src/components/useTokenClaims.spec.tsx +194 -0
- package/src/cookie.spec.ts +276 -0
- package/src/cookie.ts +56 -0
- package/src/get-authorization-url.spec.ts +60 -0
- package/src/get-authorization-url.ts +2 -0
- package/src/interfaces.ts +3 -0
- package/src/jwt.spec.ts +159 -0
- package/src/middleware.ts +2 -1
- package/src/session.spec.ts +1152 -0
- package/src/session.ts +42 -2
- package/src/test-helpers.ts +70 -0
- package/src/utils.spec.ts +142 -0
- package/src/workos.spec.ts +67 -0
- package/src/workos.ts +1 -1
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { cookies, headers } from 'next/headers';
|
|
3
|
+
import { redirect } from 'next/navigation';
|
|
4
|
+
import { generateTestToken } from './test-helpers.js';
|
|
5
|
+
import { withAuth, updateSession, refreshSession, updateSessionMiddleware, getTokenClaims } from './session.js';
|
|
6
|
+
import { getWorkOS } from './workos.js';
|
|
7
|
+
import * as envVariables from './env-variables.js';
|
|
8
|
+
|
|
9
|
+
import { jwtVerify } from 'jose';
|
|
10
|
+
import { sealData } from 'iron-session';
|
|
11
|
+
import { User } from '@workos-inc/node';
|
|
12
|
+
|
|
13
|
+
jest.mock('jose', () => ({
|
|
14
|
+
jwtVerify: jest.fn(),
|
|
15
|
+
createRemoteJWKSet: jest.fn(),
|
|
16
|
+
SignJWT: jest.requireActual('jose').SignJWT,
|
|
17
|
+
decodeJwt: jest.requireActual('jose').decodeJwt,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// logging is disabled by default, flip this to true to still have logs in the console
|
|
21
|
+
const DEBUG = false;
|
|
22
|
+
|
|
23
|
+
const workos = getWorkOS();
|
|
24
|
+
|
|
25
|
+
describe('session.ts', () => {
|
|
26
|
+
const mockSession = {
|
|
27
|
+
accessToken: 'access-token',
|
|
28
|
+
oauthTokens: undefined,
|
|
29
|
+
sessionId: 'session_123',
|
|
30
|
+
organizationId: 'org_123',
|
|
31
|
+
role: 'member',
|
|
32
|
+
permissions: ['posts:create', 'posts:delete'],
|
|
33
|
+
entitlements: ['audit-logs'],
|
|
34
|
+
featureFlags: ['device-authorization-grant'],
|
|
35
|
+
impersonator: undefined,
|
|
36
|
+
user: {
|
|
37
|
+
object: 'user',
|
|
38
|
+
id: 'user_123',
|
|
39
|
+
email: 'test@example.com',
|
|
40
|
+
emailVerified: true,
|
|
41
|
+
profilePictureUrl: null,
|
|
42
|
+
firstName: null,
|
|
43
|
+
lastName: null,
|
|
44
|
+
createdAt: '2024-01-01',
|
|
45
|
+
updatedAt: '2024-01-01',
|
|
46
|
+
} as User,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let consoleLogSpy: jest.SpyInstance;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
// Clear all mocks between tests
|
|
53
|
+
jest.clearAllMocks();
|
|
54
|
+
|
|
55
|
+
// Reset the cookie store
|
|
56
|
+
const nextCookies = await cookies();
|
|
57
|
+
// @ts-expect-error - _reset is part of the mock
|
|
58
|
+
nextCookies._reset();
|
|
59
|
+
|
|
60
|
+
const nextHeaders = await headers();
|
|
61
|
+
// @ts-expect-error - _reset is part of the mock
|
|
62
|
+
nextHeaders._reset();
|
|
63
|
+
nextHeaders.set('x-workos-middleware', 'true');
|
|
64
|
+
|
|
65
|
+
(jwtVerify as jest.Mock).mockReset();
|
|
66
|
+
|
|
67
|
+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation((...args) => {
|
|
68
|
+
if (DEBUG) {
|
|
69
|
+
console.info(...args);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
consoleLogSpy.mockRestore();
|
|
76
|
+
jest.resetModules();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('withAuth', () => {
|
|
80
|
+
it('should return user info when authenticated', async () => {
|
|
81
|
+
mockSession.accessToken = await generateTestToken();
|
|
82
|
+
|
|
83
|
+
const nextHeaders = await headers();
|
|
84
|
+
|
|
85
|
+
nextHeaders.set(
|
|
86
|
+
'x-workos-session',
|
|
87
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const result = await withAuth();
|
|
91
|
+
expect(result).toHaveProperty('user');
|
|
92
|
+
expect(result.user).toEqual(mockSession.user);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return null when user is not authenticated', async () => {
|
|
96
|
+
const result = await withAuth();
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual({ user: null });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should redirect when ensureSignedIn is true and user is not authenticated', async () => {
|
|
102
|
+
const nextHeaders = await headers();
|
|
103
|
+
nextHeaders.set('x-url', 'https://example.com/protected');
|
|
104
|
+
|
|
105
|
+
await withAuth({ ensureSignedIn: true });
|
|
106
|
+
|
|
107
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should throw an error if the route is not covered by the middleware', async () => {
|
|
111
|
+
const nextHeaders = await headers();
|
|
112
|
+
nextHeaders.delete('x-workos-middleware');
|
|
113
|
+
nextHeaders.set('x-url', 'https://example.com/');
|
|
114
|
+
|
|
115
|
+
await expect(async () => {
|
|
116
|
+
await withAuth();
|
|
117
|
+
}).rejects.toThrow(
|
|
118
|
+
"You are calling 'withAuth' on https://example.com/ that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should throw an error if the route is not covered by the middleware and there is no URL in the headers', async () => {
|
|
123
|
+
const nextHeaders = await headers();
|
|
124
|
+
nextHeaders.delete('x-workos-middleware');
|
|
125
|
+
|
|
126
|
+
await expect(async () => {
|
|
127
|
+
await withAuth({ ensureSignedIn: true });
|
|
128
|
+
}).rejects.toThrow(
|
|
129
|
+
"You are calling 'withAuth' on a route that isn’t covered by the AuthKit middleware. Make sure it is running on all paths you are calling 'withAuth' from by updating your middleware config in 'middleware.(js|ts)'.",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should throw an error if the URL is not found in the headers', async () => {
|
|
134
|
+
const nextHeaders = await headers();
|
|
135
|
+
nextHeaders.delete('x-url');
|
|
136
|
+
|
|
137
|
+
await expect(async () => {
|
|
138
|
+
await withAuth({ ensureSignedIn: true });
|
|
139
|
+
}).rejects.toThrow('No URL found in the headers');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should include any search parameters in the redirect URL', async () => {
|
|
143
|
+
const nextHeaders = await headers();
|
|
144
|
+
nextHeaders.set('x-url', 'https://example.com/protected?test=123');
|
|
145
|
+
|
|
146
|
+
await withAuth({ ensureSignedIn: true });
|
|
147
|
+
|
|
148
|
+
const pathname = encodeURIComponent(btoa(JSON.stringify({ returnPathname: '/protected?test=123' })));
|
|
149
|
+
|
|
150
|
+
expect(redirect).toHaveBeenCalledWith(expect.stringContaining(pathname));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('updateSessionMiddleware', () => {
|
|
155
|
+
it('should throw an error if the redirect URI is not set', async () => {
|
|
156
|
+
const originalWorkosRedirectUri = envVariables.WORKOS_REDIRECT_URI;
|
|
157
|
+
|
|
158
|
+
jest.replaceProperty(envVariables, 'WORKOS_REDIRECT_URI', '');
|
|
159
|
+
|
|
160
|
+
await expect(async () => {
|
|
161
|
+
await updateSessionMiddleware(
|
|
162
|
+
new NextRequest(new URL('http://example.com')),
|
|
163
|
+
false,
|
|
164
|
+
{
|
|
165
|
+
enabled: false,
|
|
166
|
+
unauthenticatedPaths: [],
|
|
167
|
+
},
|
|
168
|
+
'',
|
|
169
|
+
[],
|
|
170
|
+
);
|
|
171
|
+
}).rejects.toThrow('You must provide a redirect URI in the AuthKit middleware or in the environment variables.');
|
|
172
|
+
|
|
173
|
+
jest.replaceProperty(envVariables, 'WORKOS_REDIRECT_URI', originalWorkosRedirectUri);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should throw an error if the cookie password is not set', async () => {
|
|
177
|
+
const originalWorkosCookiePassword = envVariables.WORKOS_COOKIE_PASSWORD;
|
|
178
|
+
|
|
179
|
+
jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', '');
|
|
180
|
+
|
|
181
|
+
await expect(async () => {
|
|
182
|
+
await updateSessionMiddleware(
|
|
183
|
+
new NextRequest(new URL('http://example.com')),
|
|
184
|
+
false,
|
|
185
|
+
{
|
|
186
|
+
enabled: false,
|
|
187
|
+
unauthenticatedPaths: [],
|
|
188
|
+
},
|
|
189
|
+
'',
|
|
190
|
+
[],
|
|
191
|
+
);
|
|
192
|
+
}).rejects.toThrow(
|
|
193
|
+
'You must provide a valid cookie password that is at least 32 characters in the environment variables.',
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should throw an error if the cookie password is less than 32 characters', async () => {
|
|
200
|
+
const originalWorkosCookiePassword = envVariables.WORKOS_COOKIE_PASSWORD;
|
|
201
|
+
|
|
202
|
+
jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', 'short');
|
|
203
|
+
|
|
204
|
+
await expect(async () => {
|
|
205
|
+
await updateSessionMiddleware(
|
|
206
|
+
new NextRequest(new URL('http://example.com')),
|
|
207
|
+
false,
|
|
208
|
+
{
|
|
209
|
+
enabled: false,
|
|
210
|
+
unauthenticatedPaths: [],
|
|
211
|
+
},
|
|
212
|
+
'',
|
|
213
|
+
[],
|
|
214
|
+
);
|
|
215
|
+
}).rejects.toThrow(
|
|
216
|
+
'You must provide a valid cookie password that is at least 32 characters in the environment variables.',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
jest.replaceProperty(envVariables, 'WORKOS_COOKIE_PASSWORD', originalWorkosCookiePassword);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should return early if there is no session', async () => {
|
|
223
|
+
const request = new NextRequest(new URL('http://example.com'));
|
|
224
|
+
const result = await updateSessionMiddleware(
|
|
225
|
+
request,
|
|
226
|
+
false,
|
|
227
|
+
{
|
|
228
|
+
enabled: false,
|
|
229
|
+
unauthenticatedPaths: [],
|
|
230
|
+
},
|
|
231
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
232
|
+
[],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(result).toBeInstanceOf(NextResponse);
|
|
236
|
+
expect(result.status).toBe(200);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should return 200 if the session is valid', async () => {
|
|
240
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
241
|
+
|
|
242
|
+
const nextCookies = await cookies();
|
|
243
|
+
nextCookies.set(
|
|
244
|
+
'wos-session',
|
|
245
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const request = new NextRequest(new URL('http://example.com'));
|
|
253
|
+
const result = await updateSessionMiddleware(
|
|
254
|
+
request,
|
|
255
|
+
true,
|
|
256
|
+
{
|
|
257
|
+
enabled: false,
|
|
258
|
+
unauthenticatedPaths: [],
|
|
259
|
+
},
|
|
260
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
261
|
+
[],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(result).toBeInstanceOf(NextResponse);
|
|
265
|
+
expect(result.status).toBe(200);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should attempt to refresh the session when the access token is invalid', async () => {
|
|
269
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
270
|
+
|
|
271
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
272
|
+
throw new Error('Invalid token');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
|
|
276
|
+
accessToken: await generateTestToken(),
|
|
277
|
+
refreshToken: 'new-refresh-token',
|
|
278
|
+
user: mockSession.user,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const request = new NextRequest(new URL('http://example.com'));
|
|
282
|
+
|
|
283
|
+
request.cookies.set(
|
|
284
|
+
'wos-session',
|
|
285
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const result = await updateSessionMiddleware(
|
|
289
|
+
request,
|
|
290
|
+
true,
|
|
291
|
+
{
|
|
292
|
+
enabled: false,
|
|
293
|
+
unauthenticatedPaths: [],
|
|
294
|
+
},
|
|
295
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
296
|
+
[],
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(result.status).toBe(200);
|
|
300
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
301
|
+
`Session invalid. Refreshing access token that ends in ${mockSession.accessToken.slice(-10)}`,
|
|
302
|
+
);
|
|
303
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Session successfully refreshed'));
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should delete the cookie when refreshing fails', async () => {
|
|
307
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
308
|
+
|
|
309
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
310
|
+
|
|
311
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
312
|
+
throw new Error('Invalid token');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
jest
|
|
316
|
+
.spyOn(workos.userManagement, 'authenticateWithRefreshToken')
|
|
317
|
+
.mockRejectedValue(new Error('Failed to refresh'));
|
|
318
|
+
|
|
319
|
+
const request = new NextRequest(new URL('http://example.com'));
|
|
320
|
+
|
|
321
|
+
request.cookies.set(
|
|
322
|
+
'wos-session',
|
|
323
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const response = await updateSessionMiddleware(
|
|
327
|
+
request,
|
|
328
|
+
true,
|
|
329
|
+
{
|
|
330
|
+
enabled: false,
|
|
331
|
+
unauthenticatedPaths: [],
|
|
332
|
+
},
|
|
333
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
334
|
+
[],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(response.status).toBe(200);
|
|
338
|
+
expect(response.headers.get('Set-Cookie')).toContain('wos-session=;');
|
|
339
|
+
expect(console.log).toHaveBeenCalledTimes(2);
|
|
340
|
+
expect(console.log).toHaveBeenNthCalledWith(
|
|
341
|
+
1,
|
|
342
|
+
`Session invalid. Refreshing access token that ends in ${mockSession.accessToken.slice(-10)}`,
|
|
343
|
+
);
|
|
344
|
+
expect(console.log).toHaveBeenNthCalledWith(
|
|
345
|
+
2,
|
|
346
|
+
'Failed to refresh. Deleting cookie.',
|
|
347
|
+
new Error('Failed to refresh'),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('middleware auth', () => {
|
|
352
|
+
it('should redirect unauthenticated users on protected routes', async () => {
|
|
353
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
354
|
+
|
|
355
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
356
|
+
const result = await updateSessionMiddleware(
|
|
357
|
+
request,
|
|
358
|
+
true,
|
|
359
|
+
{
|
|
360
|
+
enabled: true,
|
|
361
|
+
unauthenticatedPaths: [],
|
|
362
|
+
},
|
|
363
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
364
|
+
[],
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
expect(result.status).toBe(307);
|
|
368
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
369
|
+
'Unauthenticated user on protected route http://example.com/protected, redirecting to AuthKit',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should use Response if NextResponse.redirect is not available', async () => {
|
|
374
|
+
const originalRedirect = NextResponse.redirect;
|
|
375
|
+
(NextResponse as Partial<typeof NextResponse>).redirect = undefined;
|
|
376
|
+
|
|
377
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
378
|
+
const result = await updateSessionMiddleware(
|
|
379
|
+
request,
|
|
380
|
+
false,
|
|
381
|
+
{
|
|
382
|
+
enabled: true,
|
|
383
|
+
unauthenticatedPaths: [],
|
|
384
|
+
},
|
|
385
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
386
|
+
[],
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
expect(result).toBeInstanceOf(Response);
|
|
390
|
+
|
|
391
|
+
// Restore the original redirect method
|
|
392
|
+
(NextResponse as Partial<typeof NextResponse>).redirect = originalRedirect;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('should automatically add the redirect URI to unauthenticatedPaths when middleware is enabled', async () => {
|
|
396
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
397
|
+
const result = await updateSessionMiddleware(
|
|
398
|
+
request,
|
|
399
|
+
false,
|
|
400
|
+
{
|
|
401
|
+
enabled: true,
|
|
402
|
+
unauthenticatedPaths: [],
|
|
403
|
+
},
|
|
404
|
+
'http://example.com/protected',
|
|
405
|
+
[],
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
expect(result.status).toBe(200);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should redirect unauthenticated users to sign up page on protected routes included in signUpPaths', async () => {
|
|
412
|
+
const request = new NextRequest(new URL('http://example.com/protected-signup'));
|
|
413
|
+
const result = await updateSessionMiddleware(
|
|
414
|
+
request,
|
|
415
|
+
false,
|
|
416
|
+
{
|
|
417
|
+
enabled: true,
|
|
418
|
+
unauthenticatedPaths: [],
|
|
419
|
+
},
|
|
420
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
421
|
+
['/protected-signup'],
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
expect(result.status).toBe(307);
|
|
425
|
+
expect(result.headers.get('Location')).toContain('screen_hint=sign-up');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should set the sign up paths in the headers', async () => {
|
|
429
|
+
const request = new NextRequest(new URL('http://example.com/protected-signup'));
|
|
430
|
+
const result = await updateSessionMiddleware(
|
|
431
|
+
request,
|
|
432
|
+
false,
|
|
433
|
+
{
|
|
434
|
+
enabled: false,
|
|
435
|
+
unauthenticatedPaths: [],
|
|
436
|
+
},
|
|
437
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
438
|
+
['/protected-signup'],
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
expect(result.headers.get('x-sign-up-paths')).toBe('/protected-signup');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should allow logged out users on unauthenticated paths', async () => {
|
|
445
|
+
const request = new NextRequest(new URL('http://example.com/unauthenticated'));
|
|
446
|
+
const result = await updateSessionMiddleware(
|
|
447
|
+
request,
|
|
448
|
+
false,
|
|
449
|
+
{
|
|
450
|
+
enabled: true,
|
|
451
|
+
unauthenticatedPaths: ['/unauthenticated'],
|
|
452
|
+
},
|
|
453
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
454
|
+
[],
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
expect(result.status).toBe(200);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should throw an error if the provided regex is invalid', async () => {
|
|
461
|
+
const request = new NextRequest(new URL('http://example.com/invalid-regex'));
|
|
462
|
+
await expect(async () => {
|
|
463
|
+
await updateSessionMiddleware(
|
|
464
|
+
request,
|
|
465
|
+
false,
|
|
466
|
+
{
|
|
467
|
+
enabled: true,
|
|
468
|
+
unauthenticatedPaths: ['[*'],
|
|
469
|
+
},
|
|
470
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
471
|
+
[],
|
|
472
|
+
);
|
|
473
|
+
}).rejects.toThrow();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should throw an error if the provided regex is invalid and a non-Error object is thrown', async () => {
|
|
477
|
+
// Reset modules to ensure clean import state
|
|
478
|
+
jest.resetModules();
|
|
479
|
+
|
|
480
|
+
// Import first, then spy
|
|
481
|
+
const pathToRegexp = await import('path-to-regexp');
|
|
482
|
+
const parseSpy = jest.spyOn(pathToRegexp, 'parse').mockImplementation(() => {
|
|
483
|
+
throw 'invalid regex';
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// Import session after setting up the spy
|
|
487
|
+
const { updateSessionMiddleware } = await import('./session.js');
|
|
488
|
+
|
|
489
|
+
const request = new NextRequest(new URL('http://example.com/invalid-regex'));
|
|
490
|
+
|
|
491
|
+
await expect(async () => {
|
|
492
|
+
await updateSessionMiddleware(
|
|
493
|
+
request,
|
|
494
|
+
false,
|
|
495
|
+
{
|
|
496
|
+
enabled: true,
|
|
497
|
+
unauthenticatedPaths: ['[*'],
|
|
498
|
+
},
|
|
499
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
500
|
+
[],
|
|
501
|
+
);
|
|
502
|
+
}).rejects.toThrow('Error parsing routes for middleware auth. Reason: invalid regex');
|
|
503
|
+
|
|
504
|
+
// Verify the mock was called
|
|
505
|
+
expect(parseSpy).toHaveBeenCalled();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('should default to the WORKOS_REDIRECT_URI environment variable if no redirect URI is provided', async () => {
|
|
509
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
510
|
+
const result = await updateSessionMiddleware(
|
|
511
|
+
request,
|
|
512
|
+
false,
|
|
513
|
+
{
|
|
514
|
+
enabled: true,
|
|
515
|
+
unauthenticatedPaths: [],
|
|
516
|
+
},
|
|
517
|
+
'',
|
|
518
|
+
[],
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
expect(result.status).toBe(307);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should delete the cookie and redirect when refreshing fails', async () => {
|
|
525
|
+
jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
526
|
+
|
|
527
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
528
|
+
|
|
529
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
530
|
+
throw new Error('Invalid token');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
jest
|
|
534
|
+
.spyOn(workos.userManagement, 'authenticateWithRefreshToken')
|
|
535
|
+
.mockRejectedValue(new Error('Failed to refresh'));
|
|
536
|
+
|
|
537
|
+
const request = new NextRequest(new URL('http://example.com'));
|
|
538
|
+
|
|
539
|
+
request.cookies.set(
|
|
540
|
+
'wos-session',
|
|
541
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const response = await updateSessionMiddleware(
|
|
545
|
+
request,
|
|
546
|
+
true,
|
|
547
|
+
{
|
|
548
|
+
enabled: true,
|
|
549
|
+
unauthenticatedPaths: [],
|
|
550
|
+
},
|
|
551
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
552
|
+
[],
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
expect(response.status).toBe(307);
|
|
556
|
+
expect(response.headers.get('Set-Cookie')).toContain('wos-session=;');
|
|
557
|
+
expect(console.log).toHaveBeenCalledTimes(3);
|
|
558
|
+
expect(console.log).toHaveBeenNthCalledWith(
|
|
559
|
+
1,
|
|
560
|
+
`Session invalid. Refreshing access token that ends in ${mockSession.accessToken.slice(-10)}`,
|
|
561
|
+
);
|
|
562
|
+
expect(console.log).toHaveBeenNthCalledWith(
|
|
563
|
+
2,
|
|
564
|
+
'Failed to refresh. Deleting cookie.',
|
|
565
|
+
new Error('Failed to refresh'),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
expect(console.log).toHaveBeenNthCalledWith(
|
|
569
|
+
3,
|
|
570
|
+
'Unauthenticated user on protected route http://example.com/, redirecting to AuthKit',
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
describe('sign up paths', () => {
|
|
575
|
+
it('should redirect to sign up when unauthenticated user is on a sign up path', async () => {
|
|
576
|
+
const request = new NextRequest(new URL('http://example.com/signup'));
|
|
577
|
+
|
|
578
|
+
const result = await updateSessionMiddleware(
|
|
579
|
+
request,
|
|
580
|
+
false,
|
|
581
|
+
{
|
|
582
|
+
enabled: true,
|
|
583
|
+
unauthenticatedPaths: [],
|
|
584
|
+
},
|
|
585
|
+
process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
|
|
586
|
+
['/signup'],
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
expect(result.status).toBe(307);
|
|
590
|
+
expect(result.headers.get('Location')).toContain('screen_hint=sign-up');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('should accept a sign up path as a string', async () => {
|
|
594
|
+
const nextHeaders = await headers();
|
|
595
|
+
nextHeaders.set('x-url', 'http://example.com/signup');
|
|
596
|
+
nextHeaders.set('x-sign-up-paths', '/signup');
|
|
597
|
+
|
|
598
|
+
await withAuth({ ensureSignedIn: true });
|
|
599
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
600
|
+
expect(redirect).toHaveBeenCalledWith(expect.stringContaining('screen_hint=sign-up'));
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
describe('updateSession', () => {
|
|
607
|
+
it('should return an authorization url if the session is invalid', async () => {
|
|
608
|
+
const result = await updateSession(new NextRequest(new URL('http://example.com/protected')), {
|
|
609
|
+
debug: true,
|
|
610
|
+
screenHint: 'sign-up',
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
expect(result.authorizationUrl).toBeDefined();
|
|
614
|
+
expect(result.authorizationUrl).toContain('screen_hint=sign-up');
|
|
615
|
+
expect(result.session.user).toBeNull();
|
|
616
|
+
expect(console.log).toHaveBeenCalledWith('No session found from cookie');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('should return a session if the session is valid', async () => {
|
|
620
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
621
|
+
request.cookies.set(
|
|
622
|
+
'wos-session',
|
|
623
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const result = await updateSession(request);
|
|
627
|
+
|
|
628
|
+
expect(result.session).toBeDefined();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('should attempt to refresh an invalid session', async () => {
|
|
632
|
+
// Setup invalid session
|
|
633
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
634
|
+
|
|
635
|
+
// Mock token verification to fail
|
|
636
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
637
|
+
throw new Error('Invalid token');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Mock successful refresh
|
|
641
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
|
|
642
|
+
accessToken: await generateTestToken(),
|
|
643
|
+
refreshToken: 'new-refresh-token',
|
|
644
|
+
user: mockSession.user,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
648
|
+
request.cookies.set(
|
|
649
|
+
'wos-session',
|
|
650
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const response = await updateSession(request, {
|
|
654
|
+
debug: true,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
expect(response.session).toBeDefined();
|
|
658
|
+
expect(response.session.user).toBeDefined();
|
|
659
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
660
|
+
expect.stringContaining('Session invalid. Refreshing access token that ends in'),
|
|
661
|
+
);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('should handle refresh failure by returning auth URL', async () => {
|
|
665
|
+
// Setup invalid session
|
|
666
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
667
|
+
|
|
668
|
+
// Mock token verification to fail
|
|
669
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
670
|
+
throw new Error('Invalid token');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// Mock refresh failure
|
|
674
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(new Error('Refresh failed'));
|
|
675
|
+
|
|
676
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
677
|
+
request.cookies.set(
|
|
678
|
+
'wos-session',
|
|
679
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const response = await updateSession(request, {
|
|
683
|
+
debug: true,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
expect(response.session.user).toBeNull();
|
|
687
|
+
expect(response.authorizationUrl).toBeDefined();
|
|
688
|
+
expect(console.log).toHaveBeenCalledWith('Failed to refresh. Deleting cookie.', expect.any(Error));
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('should call onSessionRefreshSuccess when refresh succeeds', async () => {
|
|
692
|
+
// Setup invalid session
|
|
693
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
694
|
+
|
|
695
|
+
// Mock token verification to fail
|
|
696
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
697
|
+
throw new Error('Invalid token');
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const newAccessToken = await generateTestToken();
|
|
701
|
+
const mockSuccessCallback = jest.fn();
|
|
702
|
+
|
|
703
|
+
// Mock successful refresh
|
|
704
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
|
|
705
|
+
accessToken: newAccessToken,
|
|
706
|
+
refreshToken: 'new-refresh-token',
|
|
707
|
+
user: mockSession.user,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
711
|
+
request.cookies.set(
|
|
712
|
+
'wos-session',
|
|
713
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
await updateSession(request, {
|
|
717
|
+
debug: true,
|
|
718
|
+
onSessionRefreshSuccess: mockSuccessCallback,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
expect(mockSuccessCallback).toHaveBeenCalledTimes(1);
|
|
722
|
+
expect(mockSuccessCallback).toHaveBeenCalledWith({
|
|
723
|
+
accessToken: newAccessToken,
|
|
724
|
+
user: mockSession.user,
|
|
725
|
+
impersonator: undefined,
|
|
726
|
+
organizationId: 'org_123',
|
|
727
|
+
});
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('should call onSessionRefreshError when refresh fails', async () => {
|
|
731
|
+
// Setup invalid session
|
|
732
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
733
|
+
|
|
734
|
+
// Mock token verification to fail
|
|
735
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
736
|
+
throw new Error('Invalid token');
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const mockError = new Error('Refresh failed');
|
|
740
|
+
const mockErrorCallback = jest.fn();
|
|
741
|
+
|
|
742
|
+
// Mock refresh failure
|
|
743
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(mockError);
|
|
744
|
+
|
|
745
|
+
const request = new NextRequest(new URL('http://example.com/protected'));
|
|
746
|
+
request.cookies.set(
|
|
747
|
+
'wos-session',
|
|
748
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
await updateSession(request, {
|
|
752
|
+
debug: true,
|
|
753
|
+
onSessionRefreshError: mockErrorCallback,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
expect(mockErrorCallback).toHaveBeenCalledTimes(1);
|
|
757
|
+
expect(mockErrorCallback).toHaveBeenCalledWith({
|
|
758
|
+
error: mockError,
|
|
759
|
+
request,
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe('refreshSession', () => {
|
|
765
|
+
it('should refresh session successfully', async () => {
|
|
766
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
|
|
767
|
+
accessToken: await generateTestToken(),
|
|
768
|
+
refreshToken: 'new-refresh-token',
|
|
769
|
+
user: mockSession.user,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
jest
|
|
773
|
+
.spyOn(workos.userManagement, 'getJwksUrl')
|
|
774
|
+
.mockReturnValue('https://api.workos.com/sso/jwks/client_1234567890');
|
|
775
|
+
|
|
776
|
+
const nextCookies = await cookies();
|
|
777
|
+
nextCookies.set(
|
|
778
|
+
'wos-session',
|
|
779
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const result = await refreshSession({ ensureSignedIn: false });
|
|
783
|
+
|
|
784
|
+
expect(result).toHaveProperty('user');
|
|
785
|
+
expect(result).toHaveProperty('accessToken');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should return null user when no session exists', async () => {
|
|
789
|
+
const result = await refreshSession({ ensureSignedIn: false });
|
|
790
|
+
expect(result).toEqual({ user: null });
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it('should redirect to sign in when ensureSignedIn is true and no session exists', async () => {
|
|
794
|
+
const nextHeaders = await headers();
|
|
795
|
+
nextHeaders.set('x-url', 'http://example.com/protected');
|
|
796
|
+
|
|
797
|
+
const response = await refreshSession({ ensureSignedIn: true });
|
|
798
|
+
|
|
799
|
+
expect(response).toEqual({ user: null });
|
|
800
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('should use the organizationId provided in the options', async () => {
|
|
804
|
+
const nextCookies = await cookies();
|
|
805
|
+
nextCookies.set(
|
|
806
|
+
'wos-session',
|
|
807
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
|
|
811
|
+
accessToken: await generateTestToken({ org_id: 'org_456' }),
|
|
812
|
+
refreshToken: 'new-refresh-token',
|
|
813
|
+
user: mockSession.user,
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
jest
|
|
817
|
+
.spyOn(workos.userManagement, 'getJwksUrl')
|
|
818
|
+
.mockReturnValue('https://api.workos.com/sso/jwks/client_1234567890');
|
|
819
|
+
|
|
820
|
+
const result = await refreshSession({ organizationId: 'org_456' });
|
|
821
|
+
|
|
822
|
+
expect(result).toHaveProperty('user');
|
|
823
|
+
expect(result.organizationId).toBe('org_456');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('throws if authenticateWithRefreshToken fails with string', async () => {
|
|
827
|
+
const nextCookies = await cookies();
|
|
828
|
+
nextCookies.set(
|
|
829
|
+
'wos-session',
|
|
830
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
831
|
+
);
|
|
832
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue('fail');
|
|
833
|
+
expect(refreshSession({ ensureSignedIn: false })).rejects.toThrow('fail');
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('throws if authenticateWithRefreshToken fails with error', async () => {
|
|
837
|
+
const nextCookies = await cookies();
|
|
838
|
+
nextCookies.set(
|
|
839
|
+
'wos-session',
|
|
840
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
841
|
+
);
|
|
842
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockRejectedValue(new Error('error'));
|
|
843
|
+
await expect(refreshSession()).rejects.toThrow('error');
|
|
844
|
+
});
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
describe('getTokenClaims', () => {
|
|
848
|
+
beforeEach(async () => {
|
|
849
|
+
const nextCookies = await cookies();
|
|
850
|
+
// @ts-expect-error - _reset is part of the mock
|
|
851
|
+
nextCookies._reset();
|
|
852
|
+
jest.clearAllMocks();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('should return all token claims when accessToken is provided', async () => {
|
|
856
|
+
const tokenPayload = {
|
|
857
|
+
sub: 'user_123',
|
|
858
|
+
org_id: 'org_123',
|
|
859
|
+
role: 'admin',
|
|
860
|
+
permissions: ['read', 'write'],
|
|
861
|
+
entitlements: ['feature_a'],
|
|
862
|
+
feature_flags: ['device-authorization-grant'],
|
|
863
|
+
department: 'engineering',
|
|
864
|
+
level: 5,
|
|
865
|
+
metadata: { theme: 'dark' },
|
|
866
|
+
};
|
|
867
|
+
const token = await generateTestToken(tokenPayload);
|
|
868
|
+
|
|
869
|
+
const result = await getTokenClaims(token);
|
|
870
|
+
|
|
871
|
+
expect(result).toMatchObject(tokenPayload);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('should return empty object when no accessToken is provided and no session exists', async () => {
|
|
875
|
+
const result = await getTokenClaims();
|
|
876
|
+
|
|
877
|
+
expect(result).toEqual({});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('should return all standard claims when token has only standard claims', async () => {
|
|
881
|
+
const tokenPayload = {
|
|
882
|
+
sub: 'user_123',
|
|
883
|
+
org_id: 'org_123',
|
|
884
|
+
role: 'admin',
|
|
885
|
+
permissions: ['read', 'write'],
|
|
886
|
+
entitlements: ['feature_a'],
|
|
887
|
+
feature_flags: ['device-authorization-grant'],
|
|
888
|
+
};
|
|
889
|
+
const token = await generateTestToken(tokenPayload);
|
|
890
|
+
|
|
891
|
+
const result = await getTokenClaims(token);
|
|
892
|
+
|
|
893
|
+
expect(result).toMatchObject(tokenPayload);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should return all claims including standard JWT claims', async () => {
|
|
897
|
+
const customClaims = {
|
|
898
|
+
customField: 'value',
|
|
899
|
+
anotherCustom: 42,
|
|
900
|
+
};
|
|
901
|
+
const standardClaims = {
|
|
902
|
+
aud: 'audience',
|
|
903
|
+
sub: 'user_123',
|
|
904
|
+
sid: 'session_123',
|
|
905
|
+
org_id: 'org_123',
|
|
906
|
+
role: 'admin',
|
|
907
|
+
permissions: ['read', 'write'],
|
|
908
|
+
entitlements: ['feature_a'],
|
|
909
|
+
feature_flags: ['device-authorization-grant'],
|
|
910
|
+
jti: 'jwt_123',
|
|
911
|
+
};
|
|
912
|
+
const token = await generateTestToken({ ...standardClaims, ...customClaims });
|
|
913
|
+
|
|
914
|
+
const result = await getTokenClaims(token);
|
|
915
|
+
|
|
916
|
+
expect(result).toMatchObject({ ...standardClaims, ...customClaims });
|
|
917
|
+
expect(result).toHaveProperty('exp');
|
|
918
|
+
expect(result).toHaveProperty('iat');
|
|
919
|
+
expect(result).toHaveProperty('iss');
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('should handle complex nested claims', async () => {
|
|
923
|
+
const tokenPayload = {
|
|
924
|
+
sub: 'user_123',
|
|
925
|
+
org_id: 'org_123',
|
|
926
|
+
metadata: {
|
|
927
|
+
preferences: { theme: 'dark', language: 'en' },
|
|
928
|
+
settings: ['setting1', 'setting2'],
|
|
929
|
+
},
|
|
930
|
+
tags: ['tag1', 'tag2'],
|
|
931
|
+
permissions_custom: { read: true, write: false },
|
|
932
|
+
};
|
|
933
|
+
const token = await generateTestToken(tokenPayload);
|
|
934
|
+
|
|
935
|
+
const result = await getTokenClaims(token);
|
|
936
|
+
|
|
937
|
+
expect(result).toMatchObject(tokenPayload);
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
describe('eager auth functionality', () => {
|
|
942
|
+
beforeEach(() => {
|
|
943
|
+
jest.clearAllMocks();
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
describe('isInitialDocumentRequest', () => {
|
|
947
|
+
// Since this is not exported, we'll test it indirectly through updateSession
|
|
948
|
+
it('should set JWT cookie on initial page load with eagerAuth enabled', async () => {
|
|
949
|
+
const validAccessToken = await generateTestToken();
|
|
950
|
+
const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
|
|
951
|
+
|
|
952
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
953
|
+
request.headers.set('accept', 'text/html,application/xhtml+xml');
|
|
954
|
+
|
|
955
|
+
request.cookies.set(
|
|
956
|
+
'wos-session',
|
|
957
|
+
await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
961
|
+
|
|
962
|
+
// Should have JWT cookie in response headers
|
|
963
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
964
|
+
const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
|
|
965
|
+
expect(jwtCookie).toBeDefined();
|
|
966
|
+
expect(jwtCookie).toContain(`workos-access-token=${validAccessToken}`);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('should not set JWT cookie for API requests even with eagerAuth', async () => {
|
|
970
|
+
const validAccessToken = await generateTestToken();
|
|
971
|
+
const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
|
|
972
|
+
|
|
973
|
+
const request = new NextRequest(new URL('http://example.com/api/data'));
|
|
974
|
+
request.headers.set('accept', 'application/json');
|
|
975
|
+
|
|
976
|
+
request.cookies.set(
|
|
977
|
+
'wos-session',
|
|
978
|
+
await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
982
|
+
|
|
983
|
+
// Should NOT have JWT cookie in response headers
|
|
984
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
985
|
+
const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
|
|
986
|
+
expect(jwtCookie).toBeUndefined();
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it('should not set JWT cookie for RSC requests', async () => {
|
|
990
|
+
const validAccessToken = await generateTestToken();
|
|
991
|
+
const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
|
|
992
|
+
|
|
993
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
994
|
+
request.headers.set('accept', 'text/html');
|
|
995
|
+
request.headers.set('RSC', '1');
|
|
996
|
+
|
|
997
|
+
request.cookies.set(
|
|
998
|
+
'wos-session',
|
|
999
|
+
await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
1003
|
+
|
|
1004
|
+
// Should NOT have JWT cookie for RSC requests
|
|
1005
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
1006
|
+
const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
|
|
1007
|
+
expect(jwtCookie).toBeUndefined();
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it('should not set JWT cookie for prefetch requests', async () => {
|
|
1011
|
+
const validAccessToken = await generateTestToken();
|
|
1012
|
+
const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
|
|
1013
|
+
|
|
1014
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
1015
|
+
request.headers.set('accept', 'text/html');
|
|
1016
|
+
request.headers.set('Purpose', 'prefetch');
|
|
1017
|
+
|
|
1018
|
+
request.cookies.set(
|
|
1019
|
+
'wos-session',
|
|
1020
|
+
await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
1024
|
+
|
|
1025
|
+
// Should NOT have JWT cookie for prefetch requests
|
|
1026
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
1027
|
+
const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
|
|
1028
|
+
expect(jwtCookie).toBeUndefined();
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe('JWT cookie management during session refresh', () => {
|
|
1033
|
+
it('should set JWT cookie after successful session refresh', async () => {
|
|
1034
|
+
// Setup invalid session that needs refresh
|
|
1035
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
1036
|
+
|
|
1037
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
1038
|
+
throw new Error('Invalid token');
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
const newAccessToken = await generateTestToken();
|
|
1042
|
+
jest.spyOn(workos.userManagement, 'authenticateWithRefreshToken').mockResolvedValue({
|
|
1043
|
+
accessToken: newAccessToken,
|
|
1044
|
+
refreshToken: 'new-refresh-token',
|
|
1045
|
+
user: mockSession.user,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
1049
|
+
request.headers.set('accept', 'text/html');
|
|
1050
|
+
request.cookies.set(
|
|
1051
|
+
'wos-session',
|
|
1052
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
1056
|
+
|
|
1057
|
+
// Should set JWT cookie with new token after refresh
|
|
1058
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
1059
|
+
const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
|
|
1060
|
+
expect(jwtCookie).toBeDefined();
|
|
1061
|
+
expect(jwtCookie).toContain(`workos-access-token=${newAccessToken}`);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it('should delete JWT cookie when session refresh fails', async () => {
|
|
1065
|
+
// Setup invalid session
|
|
1066
|
+
mockSession.accessToken = await generateTestToken({}, true);
|
|
1067
|
+
|
|
1068
|
+
(jwtVerify as jest.Mock).mockImplementation(() => {
|
|
1069
|
+
throw new Error('Invalid token');
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
jest
|
|
1073
|
+
.spyOn(workos.userManagement, 'authenticateWithRefreshToken')
|
|
1074
|
+
.mockRejectedValue(new Error('Refresh failed'));
|
|
1075
|
+
|
|
1076
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
1077
|
+
request.headers.set('accept', 'text/html');
|
|
1078
|
+
request.cookies.set(
|
|
1079
|
+
'wos-session',
|
|
1080
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
1084
|
+
|
|
1085
|
+
// Should delete JWT cookie on refresh failure
|
|
1086
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
1087
|
+
const jwtDeleteCookie = setCookieHeaders.find(
|
|
1088
|
+
(header) => header.includes('workos-access-token=') && header.includes('Max-Age=0'),
|
|
1089
|
+
);
|
|
1090
|
+
expect(jwtDeleteCookie).toBeDefined();
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
describe('edge cases', () => {
|
|
1095
|
+
it('should handle requests with no accept header', async () => {
|
|
1096
|
+
const validAccessToken = await generateTestToken();
|
|
1097
|
+
const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
|
|
1098
|
+
|
|
1099
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
1100
|
+
// Don't set accept header to test the || '' fallback (line 37)
|
|
1101
|
+
|
|
1102
|
+
request.cookies.set(
|
|
1103
|
+
'wos-session',
|
|
1104
|
+
await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
1108
|
+
|
|
1109
|
+
// Without accept header, should not be treated as document request
|
|
1110
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
1111
|
+
const jwtCookie = setCookieHeaders.find((header) => header.includes('workos-access-token='));
|
|
1112
|
+
expect(jwtCookie).toBeUndefined();
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it('should not set duplicate JWT cookie if one already exists with same value', async () => {
|
|
1116
|
+
const validAccessToken = await generateTestToken();
|
|
1117
|
+
const sessionWithValidToken = { ...mockSession, accessToken: validAccessToken };
|
|
1118
|
+
|
|
1119
|
+
const request = new NextRequest(new URL('http://example.com/page'));
|
|
1120
|
+
request.headers.set('accept', 'text/html');
|
|
1121
|
+
|
|
1122
|
+
// Set existing JWT cookie with same value
|
|
1123
|
+
request.cookies.set('workos-access-token', validAccessToken);
|
|
1124
|
+
|
|
1125
|
+
request.cookies.set(
|
|
1126
|
+
'wos-session',
|
|
1127
|
+
await sealData(sessionWithValidToken, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
1128
|
+
);
|
|
1129
|
+
|
|
1130
|
+
const result = await updateSession(request, { eagerAuth: true });
|
|
1131
|
+
|
|
1132
|
+
// Should NOT set another JWT cookie since one exists with same value (line 192 condition)
|
|
1133
|
+
const setCookieHeaders = result.headers.getSetCookie();
|
|
1134
|
+
const jwtCookies = setCookieHeaders.filter((header) => header.includes('workos-access-token='));
|
|
1135
|
+
expect(jwtCookies).toHaveLength(0); // No new JWT cookie should be set
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
it('should handle saveSession with string URL parameter', async () => {
|
|
1139
|
+
const { saveSession } = await import('./session.js');
|
|
1140
|
+
|
|
1141
|
+
const sessionData = {
|
|
1142
|
+
accessToken: await generateTestToken(),
|
|
1143
|
+
refreshToken: 'test-refresh-token',
|
|
1144
|
+
user: mockSession.user,
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
// Test with string URL (line 545: typeof request === 'string')
|
|
1148
|
+
await expect(saveSession(sessionData, 'https://example.com/callback')).resolves.not.toThrow();
|
|
1149
|
+
});
|
|
1150
|
+
});
|
|
1151
|
+
});
|
|
1152
|
+
});
|