@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
package/src/auth.spec.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
|
|
4
|
+
import * as session from './session.js';
|
|
5
|
+
import * as cache from 'next/cache';
|
|
6
|
+
import * as workosModule from './workos.js';
|
|
7
|
+
|
|
8
|
+
// These are mocked in jest.setup.ts
|
|
9
|
+
import { cookies, headers } from 'next/headers';
|
|
10
|
+
import { redirect } from 'next/navigation';
|
|
11
|
+
import { generateSession, generateTestToken } from './test-helpers.js';
|
|
12
|
+
import { sealData } from 'iron-session';
|
|
13
|
+
import { getWorkOS } from './workos.js';
|
|
14
|
+
|
|
15
|
+
const workos = getWorkOS();
|
|
16
|
+
|
|
17
|
+
jest.mock('next/cache', () => {
|
|
18
|
+
const actual = jest.requireActual<typeof cache>('next/cache');
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
revalidateTag: jest.fn(),
|
|
22
|
+
revalidatePath: jest.fn(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Create a fake WorkOS instance that will be used only in the "on error" tests
|
|
27
|
+
const fakeWorkosInstance = {
|
|
28
|
+
userManagement: {
|
|
29
|
+
authenticateWithRefreshToken: jest.fn(),
|
|
30
|
+
getAuthorizationUrl: jest.fn(),
|
|
31
|
+
getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
|
|
32
|
+
getLogoutUrl: jest.fn(),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const revalidatePath = jest.mocked(cache.revalidatePath);
|
|
37
|
+
const revalidateTag = jest.mocked(cache.revalidateTag);
|
|
38
|
+
// We'll only use these in the "on error" tests
|
|
39
|
+
const authenticateWithRefreshToken = fakeWorkosInstance.userManagement.authenticateWithRefreshToken;
|
|
40
|
+
const getAuthorizationUrl = fakeWorkosInstance.userManagement.getAuthorizationUrl;
|
|
41
|
+
|
|
42
|
+
jest.mock('../src/session', () => {
|
|
43
|
+
const actual = jest.requireActual<typeof session>('../src/session');
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
refreshSession: jest.fn(actual.refreshSession),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('auth.ts', () => {
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
// Clear all mocks between tests
|
|
54
|
+
jest.clearAllMocks();
|
|
55
|
+
|
|
56
|
+
// Reset the cookie store
|
|
57
|
+
const nextCookies = await cookies();
|
|
58
|
+
// @ts-expect-error - _reset is part of the mock
|
|
59
|
+
nextCookies._reset();
|
|
60
|
+
|
|
61
|
+
const nextHeaders = await headers();
|
|
62
|
+
// @ts-expect-error - _reset is part of the mock
|
|
63
|
+
nextHeaders._reset();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('getSignInUrl', () => {
|
|
67
|
+
it('should return a valid URL', async () => {
|
|
68
|
+
const url = await getSignInUrl();
|
|
69
|
+
expect(url).toBeDefined();
|
|
70
|
+
expect(() => new URL(url)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should use the organizationId if provided', async () => {
|
|
74
|
+
const url = await getSignInUrl({ organizationId: 'org_123' });
|
|
75
|
+
expect(url).toContain('organization_id=org_123');
|
|
76
|
+
expect(url).toBeDefined();
|
|
77
|
+
expect(() => new URL(url)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should not include prompt when not specified for getSignInUrl', async () => {
|
|
82
|
+
const url = await getSignInUrl();
|
|
83
|
+
expect(url).not.toContain('prompt=');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should include prompt=consent when explicitly specified for getSignInUrl', async () => {
|
|
87
|
+
const url = await getSignInUrl({ prompt: 'consent' });
|
|
88
|
+
expect(url).toContain('prompt=consent');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('getSignUpUrl', () => {
|
|
92
|
+
it('should return a valid URL', async () => {
|
|
93
|
+
const url = await getSignUpUrl();
|
|
94
|
+
expect(url).toBeDefined();
|
|
95
|
+
expect(() => new URL(url)).not.toThrow();
|
|
96
|
+
});
|
|
97
|
+
it('should not include prompt when not specified for getSignUpUrl', async () => {
|
|
98
|
+
const url = await getSignUpUrl();
|
|
99
|
+
expect(url).not.toContain('prompt=');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should include prompt=consent when explicitly specified for getSignUpUrl', async () => {
|
|
103
|
+
const url = await getSignUpUrl({ prompt: 'consent' });
|
|
104
|
+
expect(url).toContain('prompt=consent');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('switchToOrganization', () => {
|
|
109
|
+
it('should refresh the session with the new organizationId', async () => {
|
|
110
|
+
const nextHeaders = await headers();
|
|
111
|
+
nextHeaders.set('x-url', 'http://localhost/test');
|
|
112
|
+
await switchToOrganization('org_123');
|
|
113
|
+
expect(revalidatePath).toHaveBeenCalledWith('http://localhost/test');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should revalidate the path and refresh the session with the new organizationId', async () => {
|
|
117
|
+
const nextHeaders = await headers();
|
|
118
|
+
nextHeaders.set('x-url', 'http://localhost/test');
|
|
119
|
+
await switchToOrganization('org_123', { returnTo: '/test' });
|
|
120
|
+
expect(session.refreshSession).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(session.refreshSession).toHaveBeenCalledWith({ organizationId: 'org_123', ensureSignedIn: true });
|
|
122
|
+
expect(revalidatePath).toHaveBeenCalledWith('/test');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should revalidate the provided tags and refresh the session with the new organizationId', async () => {
|
|
126
|
+
const nextHeaders = await headers();
|
|
127
|
+
nextHeaders.set('x-url', 'http://localhost/test');
|
|
128
|
+
await switchToOrganization('org_123', { revalidationStrategy: 'tag', revalidationTags: ['tag1', 'tag2'] });
|
|
129
|
+
expect(revalidateTag).toHaveBeenCalledTimes(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('on error', () => {
|
|
133
|
+
beforeEach(async () => {
|
|
134
|
+
const nextHeaders = await headers();
|
|
135
|
+
nextHeaders.set('x-url', 'http://localhost/test');
|
|
136
|
+
await generateSession();
|
|
137
|
+
|
|
138
|
+
// Create a WorkOS-like object that matches what our tests need
|
|
139
|
+
const mockWorkOS = {
|
|
140
|
+
userManagement: fakeWorkosInstance.userManagement,
|
|
141
|
+
// Add minimal properties to satisfy TypeScript
|
|
142
|
+
createHttpClient: jest.fn(),
|
|
143
|
+
createWebhookClient: jest.fn(),
|
|
144
|
+
createActionsClient: jest.fn(),
|
|
145
|
+
createIronSessionProvider: jest.fn(),
|
|
146
|
+
apiKey: 'test',
|
|
147
|
+
clientId: 'test',
|
|
148
|
+
host: 'test',
|
|
149
|
+
port: 443,
|
|
150
|
+
protocol: 'https',
|
|
151
|
+
headers: {},
|
|
152
|
+
version: '0.0.0',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Apply the mock for these tests only
|
|
156
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
157
|
+
jest.spyOn(workosModule, 'getWorkOS').mockImplementation(() => mockWorkOS as any);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
// Restore all mocks after each test
|
|
162
|
+
jest.restoreAllMocks();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should redirect to sign in when error is "sso_required"', async () => {
|
|
166
|
+
authenticateWithRefreshToken.mockImplementation(() => {
|
|
167
|
+
return Promise.reject({
|
|
168
|
+
status: 500,
|
|
169
|
+
requestID: 'sso_required',
|
|
170
|
+
error: 'sso_required',
|
|
171
|
+
errorDescription: 'User must authenticate using one of the matching connections.',
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await switchToOrganization('org_123');
|
|
176
|
+
expect(getAuthorizationUrl).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' }));
|
|
177
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should redirect to sign in when error is "mfa_enrollment"', async () => {
|
|
181
|
+
authenticateWithRefreshToken.mockImplementation(() => {
|
|
182
|
+
return Promise.reject({
|
|
183
|
+
status: 500,
|
|
184
|
+
requestID: 'mfa_enrollment',
|
|
185
|
+
error: 'mfa_enrollment',
|
|
186
|
+
errorDescription: 'User must authenticate using one of the matching connections.',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await switchToOrganization('org_123');
|
|
191
|
+
expect(getAuthorizationUrl).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' }));
|
|
192
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should redirect to the authkit_redirect_url when provided', async () => {
|
|
196
|
+
authenticateWithRefreshToken.mockImplementation(() => {
|
|
197
|
+
return Promise.reject({
|
|
198
|
+
rawData: {
|
|
199
|
+
authkit_redirect_url: 'http://localhost/test',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
await switchToOrganization('org_123');
|
|
204
|
+
expect(redirect).toHaveBeenCalledWith('http://localhost/test');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('throws other errors', async () => {
|
|
208
|
+
authenticateWithRefreshToken.mockImplementation(() => {
|
|
209
|
+
return Promise.reject(new Error('Fail'));
|
|
210
|
+
});
|
|
211
|
+
await expect(switchToOrganization('org_123')).rejects.toThrow('Fail');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('signOut', () => {
|
|
217
|
+
it('should delete the cookie and redirect', async () => {
|
|
218
|
+
const nextCookies = await cookies();
|
|
219
|
+
const nextHeaders = await headers();
|
|
220
|
+
|
|
221
|
+
nextHeaders.set('x-workos-middleware', 'true');
|
|
222
|
+
nextCookies.set('wos-session', 'foo');
|
|
223
|
+
|
|
224
|
+
await signOut();
|
|
225
|
+
|
|
226
|
+
const sessionCookie = nextCookies.get('wos-session');
|
|
227
|
+
|
|
228
|
+
expect(sessionCookie).toBeUndefined();
|
|
229
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(redirect).toHaveBeenCalledWith('/');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should delete the cookie with a specific domain', async () => {
|
|
234
|
+
const nextCookies = await cookies();
|
|
235
|
+
const nextHeaders = await headers();
|
|
236
|
+
|
|
237
|
+
nextHeaders.set('x-workos-middleware', 'true');
|
|
238
|
+
nextCookies.set('wos-session', 'foo', { domain: 'example.com' });
|
|
239
|
+
|
|
240
|
+
await signOut();
|
|
241
|
+
|
|
242
|
+
const sessionCookie = nextCookies.get('wos-session');
|
|
243
|
+
expect(sessionCookie).toBeUndefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('when given a `returnTo` parameter', () => {
|
|
247
|
+
it('passes the `returnTo` through to the `getLogoutUrl` call', async () => {
|
|
248
|
+
jest
|
|
249
|
+
.spyOn(workos.userManagement, 'getLogoutUrl')
|
|
250
|
+
.mockReturnValue('https://user-management-logout.com/signed-out');
|
|
251
|
+
const mockSession = {
|
|
252
|
+
accessToken: await generateTestToken(),
|
|
253
|
+
sessionId: 'session_123',
|
|
254
|
+
} as const;
|
|
255
|
+
|
|
256
|
+
const nextHeaders = await headers();
|
|
257
|
+
nextHeaders.set(
|
|
258
|
+
'x-workos-session',
|
|
259
|
+
await sealData(mockSession, { password: process.env.WORKOS_COOKIE_PASSWORD as string }),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
nextHeaders.set('x-workos-middleware', 'true');
|
|
263
|
+
|
|
264
|
+
await signOut({ returnTo: 'https://example.com/signed-out' });
|
|
265
|
+
|
|
266
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(redirect).toHaveBeenCalledWith('https://user-management-logout.com/signed-out');
|
|
268
|
+
expect(workos.userManagement.getLogoutUrl).toHaveBeenCalledWith(
|
|
269
|
+
expect.objectContaining({
|
|
270
|
+
returnTo: 'https://example.com/signed-out',
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('when there is no session', () => {
|
|
276
|
+
it('returns to the `returnTo`', async () => {
|
|
277
|
+
const nextHeaders = await headers();
|
|
278
|
+
|
|
279
|
+
nextHeaders.set('x-workos-middleware', 'true');
|
|
280
|
+
|
|
281
|
+
await signOut({ returnTo: 'https://example.com/signed-out' });
|
|
282
|
+
|
|
283
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
284
|
+
expect(redirect).toHaveBeenCalledWith('https://example.com/signed-out');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('when called outside of middleware', () => {
|
|
290
|
+
it('should fall back to reading session from cookie and redirect to logout URL', async () => {
|
|
291
|
+
const nextCookies = await cookies();
|
|
292
|
+
|
|
293
|
+
// Don't set x-workos-middleware header to simulate being outside middleware
|
|
294
|
+
// This will cause withAuth to throw
|
|
295
|
+
|
|
296
|
+
// Set up a session cookie with a valid access token
|
|
297
|
+
const mockSession = {
|
|
298
|
+
accessToken: await generateTestToken(),
|
|
299
|
+
refreshToken: 'refresh_token',
|
|
300
|
+
user: { id: 'user_123' },
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const encryptedSession = await sealData(mockSession, {
|
|
304
|
+
password: process.env.WORKOS_COOKIE_PASSWORD as string,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
nextCookies.set('wos-session', encryptedSession);
|
|
308
|
+
|
|
309
|
+
jest
|
|
310
|
+
.spyOn(workos.userManagement, 'getLogoutUrl')
|
|
311
|
+
.mockReturnValue('https://api.workos.com/user_management/sessions/logout?session_id=session_123');
|
|
312
|
+
|
|
313
|
+
await signOut();
|
|
314
|
+
|
|
315
|
+
// Cookie should be deleted
|
|
316
|
+
const sessionCookie = nextCookies.get('wos-session');
|
|
317
|
+
expect(sessionCookie).toBeUndefined();
|
|
318
|
+
|
|
319
|
+
// Should redirect to WorkOS logout URL with session ID
|
|
320
|
+
expect(redirect).toHaveBeenCalledTimes(1);
|
|
321
|
+
expect(redirect).toHaveBeenCalledWith(
|
|
322
|
+
'https://api.workos.com/user_management/sessions/logout?session_id=session_123',
|
|
323
|
+
);
|
|
324
|
+
expect(workos.userManagement.getLogoutUrl).toHaveBeenCalledWith(
|
|
325
|
+
expect.objectContaining({
|
|
326
|
+
sessionId: expect.stringMatching(/^session_/),
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should throw the original error when no session cookie exists outside middleware', async () => {
|
|
332
|
+
const nextCookies = await cookies();
|
|
333
|
+
|
|
334
|
+
// Don't set x-workos-middleware header to simulate being outside middleware
|
|
335
|
+
// Set a cookie to verify it gets deleted
|
|
336
|
+
nextCookies.set('wos-session', 'dummy-value');
|
|
337
|
+
|
|
338
|
+
// Should throw the error from withAuth since we can't recover
|
|
339
|
+
await expect(signOut()).rejects.toThrow(/You are calling 'withAuth'/);
|
|
340
|
+
|
|
341
|
+
// Cookie should still be deleted even though it throws
|
|
342
|
+
const sessionCookie = nextCookies.get('wos-session');
|
|
343
|
+
expect(sessionCookie).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
'use server';
|
|
2
2
|
|
|
3
|
+
import { decodeJwt } from 'jose';
|
|
3
4
|
import { revalidatePath, revalidateTag } from 'next/cache';
|
|
4
5
|
import { cookies, headers } from 'next/headers';
|
|
5
6
|
import { redirect } from 'next/navigation';
|
|
6
7
|
import { WORKOS_COOKIE_NAME } from './env-variables.js';
|
|
7
8
|
import { getCookieOptions } from './cookie.js';
|
|
8
9
|
import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
9
|
-
import { SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
|
|
10
|
-
import { refreshSession, withAuth } from './session.js';
|
|
10
|
+
import type { AccessToken, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
|
|
11
|
+
import { getSessionFromCookie, refreshSession, withAuth } from './session.js';
|
|
11
12
|
import { getWorkOS } from './workos.js';
|
|
12
13
|
export async function getSignInUrl({
|
|
13
14
|
organizationId,
|
|
14
15
|
loginHint,
|
|
15
16
|
redirectUri,
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
prompt,
|
|
18
|
+
}: { organizationId?: string; loginHint?: string; redirectUri?: string; prompt?: 'consent' } = {}) {
|
|
19
|
+
return getAuthorizationUrl({ organizationId, screenHint: 'sign-in', loginHint, redirectUri, prompt });
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export async function getSignUpUrl({
|
|
21
23
|
organizationId,
|
|
22
24
|
loginHint,
|
|
23
25
|
redirectUri,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
prompt,
|
|
27
|
+
}: { organizationId?: string; loginHint?: string; redirectUri?: string; prompt?: 'consent' } = {}) {
|
|
28
|
+
return getAuthorizationUrl({ organizationId, screenHint: 'sign-up', loginHint, redirectUri, prompt });
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -36,6 +39,16 @@ export async function signOut({ returnTo }: { returnTo?: string } = {}) {
|
|
|
36
39
|
try {
|
|
37
40
|
const { sessionId: sid } = await withAuth();
|
|
38
41
|
sessionId = sid;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Fall back to reading session directly from cookie when middleware isn't available
|
|
44
|
+
const session = await getSessionFromCookie();
|
|
45
|
+
if (session && session.accessToken) {
|
|
46
|
+
const { sid } = decodeJwt<AccessToken>(session.accessToken);
|
|
47
|
+
sessionId = sid;
|
|
48
|
+
} else {
|
|
49
|
+
// can't recover - throw the original error.
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
39
52
|
} finally {
|
|
40
53
|
const nextCookies = await cookies();
|
|
41
54
|
const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { getWorkOS } from './workos.js';
|
|
2
|
+
import { handleAuth } from './authkit-callback-route.js';
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
4
|
+
|
|
5
|
+
// Mocked in jest.setup.ts
|
|
6
|
+
import { cookies, headers } from 'next/headers';
|
|
7
|
+
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
const fakeWorkosInstance = {
|
|
10
|
+
userManagement: {
|
|
11
|
+
authenticateWithCode: jest.fn(),
|
|
12
|
+
getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
jest.mock('../src/workos', () => ({
|
|
17
|
+
getWorkOS: jest.fn(() => fakeWorkosInstance),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe('authkit-callback-route', () => {
|
|
21
|
+
const workos = getWorkOS();
|
|
22
|
+
const mockAuthResponse = {
|
|
23
|
+
accessToken: 'access123',
|
|
24
|
+
refreshToken: 'refresh123',
|
|
25
|
+
user: {
|
|
26
|
+
id: 'user_123',
|
|
27
|
+
email: 'test@example.com',
|
|
28
|
+
emailVerified: true,
|
|
29
|
+
profilePictureUrl: 'https://example.com/photo.jpg',
|
|
30
|
+
firstName: 'Test',
|
|
31
|
+
lastName: 'User',
|
|
32
|
+
object: 'user' as const,
|
|
33
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
34
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
35
|
+
lastSignInAt: '2024-01-01T00:00:00Z',
|
|
36
|
+
externalId: null,
|
|
37
|
+
metadata: {},
|
|
38
|
+
},
|
|
39
|
+
oauthTokens: {
|
|
40
|
+
accessToken: 'access123',
|
|
41
|
+
refreshToken: 'refresh123',
|
|
42
|
+
expiresAt: 1719811200,
|
|
43
|
+
scopes: ['foo', 'bar'],
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('handleAuth', () => {
|
|
48
|
+
let request: NextRequest;
|
|
49
|
+
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
// Silence console.error during tests
|
|
52
|
+
jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
// Reset all mocks
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
|
|
59
|
+
// Create a new request with searchParams
|
|
60
|
+
request = new NextRequest(new URL('http://example.com/callback'));
|
|
61
|
+
|
|
62
|
+
// Reset the cookie store
|
|
63
|
+
const nextCookies = await cookies();
|
|
64
|
+
// @ts-expect-error - _reset is part of the mock
|
|
65
|
+
nextCookies._reset();
|
|
66
|
+
|
|
67
|
+
const nextHeaders = await headers();
|
|
68
|
+
// @ts-expect-error - _reset is part of the mock
|
|
69
|
+
nextHeaders._reset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle successful authentication', async () => {
|
|
73
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
74
|
+
|
|
75
|
+
// Set up request with code
|
|
76
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
77
|
+
|
|
78
|
+
const handler = handleAuth();
|
|
79
|
+
const response = await handler(request);
|
|
80
|
+
|
|
81
|
+
expect(workos.userManagement.authenticateWithCode).toHaveBeenCalledWith({
|
|
82
|
+
clientId: process.env.WORKOS_CLIENT_ID,
|
|
83
|
+
code: 'test-code',
|
|
84
|
+
});
|
|
85
|
+
expect(response).toBeInstanceOf(NextResponse);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should handle authentication failure', async () => {
|
|
89
|
+
// Mock authentication failure
|
|
90
|
+
(workos.userManagement.authenticateWithCode as jest.Mock).mockRejectedValue(new Error('Auth failed'));
|
|
91
|
+
|
|
92
|
+
request.nextUrl.searchParams.set('code', 'invalid-code');
|
|
93
|
+
|
|
94
|
+
const handler = handleAuth();
|
|
95
|
+
const response = await handler(request);
|
|
96
|
+
|
|
97
|
+
expect(response.status).toBe(500);
|
|
98
|
+
const data = await response.json();
|
|
99
|
+
expect(data.error.message).toBe('Something went wrong');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should handle authentication failure if a non-Error object is thrown', async () => {
|
|
103
|
+
// Mock authentication failure
|
|
104
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
|
|
105
|
+
|
|
106
|
+
request.nextUrl.searchParams.set('code', 'invalid-code');
|
|
107
|
+
|
|
108
|
+
const handler = handleAuth();
|
|
109
|
+
const response = await handler(request);
|
|
110
|
+
|
|
111
|
+
expect(response.status).toBe(500);
|
|
112
|
+
const data = await response.json();
|
|
113
|
+
expect(data.error.message).toBe('Something went wrong');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should handle authentication failure with custom onError handler', async () => {
|
|
117
|
+
// Mock authentication failure
|
|
118
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockRejectedValue('Auth failed');
|
|
119
|
+
request.nextUrl.searchParams.set('code', 'invalid-code');
|
|
120
|
+
|
|
121
|
+
const handler = handleAuth({
|
|
122
|
+
onError: () => {
|
|
123
|
+
return new Response(JSON.stringify({ error: { message: 'Custom error' } }), {
|
|
124
|
+
status: 500,
|
|
125
|
+
headers: { 'Content-Type': 'application/json' },
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const response = await handler(request);
|
|
130
|
+
|
|
131
|
+
expect(response.status).toBe(500);
|
|
132
|
+
const data = await response.json();
|
|
133
|
+
expect(data.error.message).toBe('Custom error');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle missing code parameter', async () => {
|
|
137
|
+
const handler = handleAuth();
|
|
138
|
+
const response = await handler(request);
|
|
139
|
+
|
|
140
|
+
expect(response.status).toBe(500);
|
|
141
|
+
const data = await response.json();
|
|
142
|
+
expect(data.error.message).toBe('Something went wrong');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should respect custom returnPathname', async () => {
|
|
146
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
147
|
+
|
|
148
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
149
|
+
|
|
150
|
+
const handler = handleAuth({ returnPathname: '/dashboard' });
|
|
151
|
+
const response = await handler(request);
|
|
152
|
+
|
|
153
|
+
expect(response.headers.get('Location')).toContain('/dashboard');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should handle state parameter with returnPathname', async () => {
|
|
157
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
158
|
+
|
|
159
|
+
const state = btoa(JSON.stringify({ returnPathname: '/custom-path' }));
|
|
160
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
161
|
+
request.nextUrl.searchParams.set('state', state);
|
|
162
|
+
|
|
163
|
+
const handler = handleAuth();
|
|
164
|
+
const response = await handler(request);
|
|
165
|
+
|
|
166
|
+
expect(response.headers.get('Location')).toContain('/custom-path');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should extract custom search params from returnPathname', async () => {
|
|
170
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
171
|
+
|
|
172
|
+
const state = btoa(JSON.stringify({ returnPathname: '/custom-path?foo=bar&baz=qux' }));
|
|
173
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
174
|
+
request.nextUrl.searchParams.set('state', state);
|
|
175
|
+
|
|
176
|
+
const handler = handleAuth();
|
|
177
|
+
const response = await handler(request);
|
|
178
|
+
|
|
179
|
+
expect(response.headers.get('Location')).toContain('/custom-path?foo=bar&baz=qux');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should use Response if NextResponse.redirect is not available', async () => {
|
|
183
|
+
const originalRedirect = NextResponse.redirect;
|
|
184
|
+
(NextResponse as Partial<typeof NextResponse>).redirect = undefined;
|
|
185
|
+
|
|
186
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
187
|
+
|
|
188
|
+
// Set up request with code
|
|
189
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
190
|
+
|
|
191
|
+
const handler = handleAuth();
|
|
192
|
+
const response = await handler(request);
|
|
193
|
+
|
|
194
|
+
expect(response).toBeInstanceOf(Response);
|
|
195
|
+
|
|
196
|
+
// Restore the original redirect method
|
|
197
|
+
(NextResponse as Partial<typeof NextResponse>).redirect = originalRedirect;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should use Response if NextResponse.json is not available', async () => {
|
|
201
|
+
const originalJson = NextResponse.json;
|
|
202
|
+
(NextResponse as Partial<typeof NextResponse>).json = undefined;
|
|
203
|
+
|
|
204
|
+
const handler = handleAuth();
|
|
205
|
+
const response = await handler(request);
|
|
206
|
+
|
|
207
|
+
expect(response).toBeInstanceOf(Response);
|
|
208
|
+
|
|
209
|
+
// Restore the original json method
|
|
210
|
+
(NextResponse as Partial<typeof NextResponse>).json = originalJson;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should throw an error if baseURL is provided but invalid', async () => {
|
|
214
|
+
expect(() => handleAuth({ baseURL: 'invalid-url' })).toThrow('Invalid baseURL: invalid-url');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should use baseURL if provided', async () => {
|
|
218
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
219
|
+
|
|
220
|
+
// Set up request with code
|
|
221
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
222
|
+
|
|
223
|
+
const handler = handleAuth({ baseURL: 'https://base.com' });
|
|
224
|
+
const response = await handler(request);
|
|
225
|
+
|
|
226
|
+
expect(response.headers.get('Location')).toContain('https://base.com');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should throw an error if response is missing tokens', async () => {
|
|
230
|
+
const mockAuthResponse = {
|
|
231
|
+
user: { id: 'user_123' },
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
(workos.userManagement.authenticateWithCode as jest.Mock).mockResolvedValue(mockAuthResponse);
|
|
235
|
+
|
|
236
|
+
// Set up request with code
|
|
237
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
238
|
+
|
|
239
|
+
const handler = handleAuth();
|
|
240
|
+
const response = await handler(request);
|
|
241
|
+
|
|
242
|
+
expect(response.status).toBe(500);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should call onSuccess if provided', async () => {
|
|
246
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
247
|
+
|
|
248
|
+
// Set up request with code
|
|
249
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
250
|
+
|
|
251
|
+
const onSuccess = jest.fn();
|
|
252
|
+
const handler = handleAuth({ onSuccess: onSuccess });
|
|
253
|
+
await handler(request);
|
|
254
|
+
|
|
255
|
+
expect(onSuccess).toHaveBeenCalledWith(mockAuthResponse);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|