@workos-inc/authkit-nextjs 2.9.0 → 2.11.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 +107 -16
- package/dist/esm/auth.js +13 -5
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/authkit-callback-route.js +37 -2
- package/dist/esm/authkit-callback-route.js.map +1 -1
- package/dist/esm/components/impersonation.js +8 -6
- package/dist/esm/components/impersonation.js.map +1 -1
- package/dist/esm/get-authorization-url.js +6 -2
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/test-helpers.js +1 -0
- package/dist/esm/test-helpers.js.map +1 -1
- package/dist/esm/types/auth.d.ts +4 -2
- package/dist/esm/types/components/impersonation.d.ts +2 -1
- package/dist/esm/types/index.d.ts +2 -1
- package/dist/esm/types/interfaces.d.ts +2 -0
- package/dist/esm/types/validate-api-key.d.ts +1 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/validate-api-key.js +17 -0
- package/dist/esm/validate-api-key.js.map +1 -0
- package/dist/esm/workos.js +1 -1
- package/dist/esm/workos.js.map +1 -1
- package/package.json +4 -4
- package/src/auth.ts +29 -5
- package/src/authkit-callback-route.spec.ts +69 -0
- package/src/authkit-callback-route.ts +35 -2
- package/src/components/impersonation.spec.tsx +136 -20
- package/src/components/impersonation.tsx +8 -6
- package/src/get-authorization-url.ts +9 -1
- package/src/index.ts +2 -0
- package/src/interfaces.ts +2 -0
- package/src/session.spec.ts +6 -1
- package/src/test-helpers.ts +1 -0
- package/src/validate-api-key.spec.ts +113 -0
- package/src/validate-api-key.ts +19 -0
- package/src/workos.ts +1 -1
package/src/auth.ts
CHANGED
|
@@ -10,13 +10,30 @@ import { getAuthorizationUrl } from './get-authorization-url.js';
|
|
|
10
10
|
import type { AccessToken, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
|
|
11
11
|
import { getSessionFromCookie, refreshSession, withAuth } from './session.js';
|
|
12
12
|
import { getWorkOS } from './workos.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A wrapper around revalidateTag to provide compatibility with previous versions.
|
|
16
|
+
* @param tag The tag to revalidate.
|
|
17
|
+
*/
|
|
18
|
+
function revalidateTagCompat(tag: string): void {
|
|
19
|
+
const fn = revalidateTag as (tag: string, profile: string) => void;
|
|
20
|
+
return fn(tag, 'max');
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
export async function getSignInUrl({
|
|
14
24
|
organizationId,
|
|
15
25
|
loginHint,
|
|
16
26
|
redirectUri,
|
|
17
27
|
prompt,
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
state,
|
|
29
|
+
}: {
|
|
30
|
+
organizationId?: string;
|
|
31
|
+
loginHint?: string;
|
|
32
|
+
redirectUri?: string;
|
|
33
|
+
prompt?: 'consent';
|
|
34
|
+
state?: string;
|
|
35
|
+
} = {}) {
|
|
36
|
+
return getAuthorizationUrl({ organizationId, screenHint: 'sign-in', loginHint, redirectUri, prompt, state });
|
|
20
37
|
}
|
|
21
38
|
|
|
22
39
|
export async function getSignUpUrl({
|
|
@@ -24,8 +41,15 @@ export async function getSignUpUrl({
|
|
|
24
41
|
loginHint,
|
|
25
42
|
redirectUri,
|
|
26
43
|
prompt,
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
state,
|
|
45
|
+
}: {
|
|
46
|
+
organizationId?: string;
|
|
47
|
+
loginHint?: string;
|
|
48
|
+
redirectUri?: string;
|
|
49
|
+
prompt?: 'consent';
|
|
50
|
+
state?: string;
|
|
51
|
+
} = {}) {
|
|
52
|
+
return getAuthorizationUrl({ organizationId, screenHint: 'sign-up', loginHint, redirectUri, prompt, state });
|
|
29
53
|
}
|
|
30
54
|
|
|
31
55
|
/**
|
|
@@ -97,7 +121,7 @@ export async function switchToOrganization(
|
|
|
97
121
|
break;
|
|
98
122
|
case 'tag':
|
|
99
123
|
for (const tag of revalidationTags) {
|
|
100
|
-
|
|
124
|
+
revalidateTagCompat(tag);
|
|
101
125
|
}
|
|
102
126
|
break;
|
|
103
127
|
}
|
|
@@ -36,6 +36,7 @@ describe('authkit-callback-route', () => {
|
|
|
36
36
|
lastSignInAt: '2024-01-01T00:00:00Z',
|
|
37
37
|
externalId: null,
|
|
38
38
|
metadata: {},
|
|
39
|
+
locale: null,
|
|
39
40
|
},
|
|
40
41
|
oauthTokens: {
|
|
41
42
|
accessToken: 'access123',
|
|
@@ -275,5 +276,73 @@ describe('authkit-callback-route', () => {
|
|
|
275
276
|
const session = await getSessionFromCookie();
|
|
276
277
|
expect(session?.accessToken).toBe(newAccessToken);
|
|
277
278
|
});
|
|
279
|
+
|
|
280
|
+
it('should pass custom state data to onSuccess callback', async () => {
|
|
281
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
282
|
+
|
|
283
|
+
// Create state with new format: internal.user
|
|
284
|
+
const internalState = btoa(JSON.stringify({ returnPathname: '/dashboard' }))
|
|
285
|
+
.replace(/\+/g, '-')
|
|
286
|
+
.replace(/\//g, '_');
|
|
287
|
+
const userState = 'custom-user-state-string';
|
|
288
|
+
const state = `${internalState}.${userState}`;
|
|
289
|
+
|
|
290
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
291
|
+
request.nextUrl.searchParams.set('state', state);
|
|
292
|
+
|
|
293
|
+
const onSuccess = jest.fn();
|
|
294
|
+
const handler = handleAuth({ onSuccess });
|
|
295
|
+
await handler(request);
|
|
296
|
+
|
|
297
|
+
// Verify onSuccess was called with the custom state string
|
|
298
|
+
expect(onSuccess).toHaveBeenCalledWith(
|
|
299
|
+
expect.objectContaining({
|
|
300
|
+
...mockAuthResponse,
|
|
301
|
+
state: 'custom-user-state-string',
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Verify the redirect went to the correct path
|
|
306
|
+
const response = await handler(request);
|
|
307
|
+
expect(response.headers.get('Location')).toContain('/dashboard');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should handle state without custom data', async () => {
|
|
311
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
312
|
+
|
|
313
|
+
// State with only returnPathname
|
|
314
|
+
const state = btoa(JSON.stringify({ returnPathname: '/profile' }));
|
|
315
|
+
|
|
316
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
317
|
+
request.nextUrl.searchParams.set('state', state);
|
|
318
|
+
|
|
319
|
+
const onSuccess = jest.fn();
|
|
320
|
+
const handler = handleAuth({ onSuccess });
|
|
321
|
+
await handler(request);
|
|
322
|
+
|
|
323
|
+
// Verify onSuccess was called without state property when no custom data exists
|
|
324
|
+
expect(onSuccess).toHaveBeenCalledWith(
|
|
325
|
+
expect.objectContaining({
|
|
326
|
+
...mockAuthResponse,
|
|
327
|
+
state: undefined,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should handle backward compatibility with old state format', async () => {
|
|
333
|
+
jest.mocked(workos.userManagement.authenticateWithCode).mockResolvedValue(mockAuthResponse);
|
|
334
|
+
|
|
335
|
+
// Old format: just returnPathname
|
|
336
|
+
const state = btoa(JSON.stringify({ returnPathname: '/old-path' }));
|
|
337
|
+
|
|
338
|
+
request.nextUrl.searchParams.set('code', 'test-code');
|
|
339
|
+
request.nextUrl.searchParams.set('state', state);
|
|
340
|
+
|
|
341
|
+
const handler = handleAuth();
|
|
342
|
+
const response = await handler(request);
|
|
343
|
+
|
|
344
|
+
// Should still redirect correctly
|
|
345
|
+
expect(response.headers.get('Location')).toContain('/old-path');
|
|
346
|
+
});
|
|
278
347
|
});
|
|
279
348
|
});
|
|
@@ -5,6 +5,37 @@ import { saveSession } from './session.js';
|
|
|
5
5
|
import { errorResponseWithFallback, redirectWithFallback } from './utils.js';
|
|
6
6
|
import { getWorkOS } from './workos.js';
|
|
7
7
|
|
|
8
|
+
function handleState(state: string | null) {
|
|
9
|
+
let returnPathname: string | undefined = undefined;
|
|
10
|
+
let userState: string | undefined;
|
|
11
|
+
if (state?.includes('.')) {
|
|
12
|
+
const [internal, ...rest] = state.split('.');
|
|
13
|
+
userState = rest.join('.');
|
|
14
|
+
try {
|
|
15
|
+
// Reverse URL-safe base64 encoding
|
|
16
|
+
const decoded = internal.replace(/-/g, '+').replace(/_/g, '/');
|
|
17
|
+
returnPathname = JSON.parse(atob(decoded)).returnPathname;
|
|
18
|
+
} catch {
|
|
19
|
+
// Malformed internal part, ignore it
|
|
20
|
+
}
|
|
21
|
+
} else if (state) {
|
|
22
|
+
try {
|
|
23
|
+
const decoded = JSON.parse(atob(state));
|
|
24
|
+
if (decoded.returnPathname) {
|
|
25
|
+
returnPathname = decoded.returnPathname;
|
|
26
|
+
} else {
|
|
27
|
+
userState = state;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
userState = state;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
returnPathname,
|
|
35
|
+
state: userState,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
export function handleAuth(options: HandleAuthOptions = {}) {
|
|
9
40
|
const { returnPathname: returnPathnameOption = '/', baseURL, onSuccess, onError } = options;
|
|
10
41
|
|
|
@@ -20,7 +51,8 @@ export function handleAuth(options: HandleAuthOptions = {}) {
|
|
|
20
51
|
return async function GET(request: NextRequest) {
|
|
21
52
|
const code = request.nextUrl.searchParams.get('code');
|
|
22
53
|
const state = request.nextUrl.searchParams.get('state');
|
|
23
|
-
|
|
54
|
+
|
|
55
|
+
const { state: customState, returnPathname: returnPathnameState } = handleState(state);
|
|
24
56
|
|
|
25
57
|
if (code) {
|
|
26
58
|
try {
|
|
@@ -41,7 +73,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
|
|
|
41
73
|
url.searchParams.delete('state');
|
|
42
74
|
|
|
43
75
|
// Redirect to the requested path and store the session
|
|
44
|
-
returnPathname =
|
|
76
|
+
const returnPathname = returnPathnameState ?? returnPathnameOption;
|
|
45
77
|
|
|
46
78
|
// Extract the search params if they are present
|
|
47
79
|
if (returnPathname.includes('?')) {
|
|
@@ -72,6 +104,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
|
|
|
72
104
|
oauthTokens,
|
|
73
105
|
authenticationMethod,
|
|
74
106
|
organizationId,
|
|
107
|
+
state: customState,
|
|
75
108
|
});
|
|
76
109
|
}
|
|
77
110
|
|
|
@@ -27,19 +27,6 @@ describe('Impersonation', () => {
|
|
|
27
27
|
impersonator: null,
|
|
28
28
|
user: { id: '123', email: 'user@example.com' },
|
|
29
29
|
organizationId: null,
|
|
30
|
-
loading: false,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
const { container } = render(<Impersonation />);
|
|
34
|
-
expect(container).toBeEmptyDOMElement();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('should return null if loading', () => {
|
|
38
|
-
(useAuth as jest.Mock).mockReturnValue({
|
|
39
|
-
impersonator: { email: 'admin@example.com' },
|
|
40
|
-
user: { id: '123', email: 'user@example.com' },
|
|
41
|
-
organizationId: null,
|
|
42
|
-
loading: true,
|
|
43
30
|
});
|
|
44
31
|
|
|
45
32
|
const { container } = render(<Impersonation />);
|
|
@@ -51,7 +38,6 @@ describe('Impersonation', () => {
|
|
|
51
38
|
impersonator: { email: 'admin@example.com' },
|
|
52
39
|
user: { id: '123', email: 'user@example.com' },
|
|
53
40
|
organizationId: null,
|
|
54
|
-
loading: false,
|
|
55
41
|
});
|
|
56
42
|
|
|
57
43
|
const { container } = render(<Impersonation />);
|
|
@@ -63,7 +49,6 @@ describe('Impersonation', () => {
|
|
|
63
49
|
impersonator: { email: 'admin@example.com' },
|
|
64
50
|
user: { id: '123', email: 'user@example.com' },
|
|
65
51
|
organizationId: 'org_123',
|
|
66
|
-
loading: false,
|
|
67
52
|
});
|
|
68
53
|
|
|
69
54
|
(getOrganizationAction as jest.Mock).mockResolvedValue({
|
|
@@ -83,7 +68,6 @@ describe('Impersonation', () => {
|
|
|
83
68
|
impersonator: { email: 'admin@example.com' },
|
|
84
69
|
user: { id: '123', email: 'user@example.com' },
|
|
85
70
|
organizationId: null,
|
|
86
|
-
loading: false,
|
|
87
71
|
});
|
|
88
72
|
|
|
89
73
|
const { container } = render(<Impersonation />);
|
|
@@ -96,7 +80,6 @@ describe('Impersonation', () => {
|
|
|
96
80
|
impersonator: { email: 'admin@example.com' },
|
|
97
81
|
user: { id: '123', email: 'user@example.com' },
|
|
98
82
|
organizationId: null,
|
|
99
|
-
loading: false,
|
|
100
83
|
});
|
|
101
84
|
|
|
102
85
|
const { container } = render(<Impersonation side="top" />);
|
|
@@ -109,7 +92,6 @@ describe('Impersonation', () => {
|
|
|
109
92
|
impersonator: { email: 'admin@example.com' },
|
|
110
93
|
user: { id: '123', email: 'user@example.com' },
|
|
111
94
|
organizationId: null,
|
|
112
|
-
loading: false,
|
|
113
95
|
});
|
|
114
96
|
|
|
115
97
|
const customStyle = { backgroundColor: 'red' };
|
|
@@ -123,12 +105,146 @@ describe('Impersonation', () => {
|
|
|
123
105
|
impersonator: { email: 'admin@example.com' },
|
|
124
106
|
user: { id: '123', email: 'user@example.com' },
|
|
125
107
|
organizationId: null,
|
|
126
|
-
loading: false,
|
|
127
108
|
});
|
|
128
109
|
|
|
129
110
|
render(<Impersonation />);
|
|
130
111
|
const stopButton = await screen.findByText('Stop');
|
|
131
112
|
stopButton.click();
|
|
132
|
-
expect(handleSignOutAction).
|
|
113
|
+
expect(handleSignOutAction).toHaveBeenCalledWith({});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should pass returnTo prop to handleSignOutAction when provided', async () => {
|
|
117
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
118
|
+
impersonator: { email: 'admin@example.com' },
|
|
119
|
+
user: { id: '123', email: 'user@example.com' },
|
|
120
|
+
organizationId: null,
|
|
121
|
+
loading: false,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const returnTo = '/dashboard';
|
|
125
|
+
render(<Impersonation returnTo={returnTo} />);
|
|
126
|
+
const stopButton = await screen.findByText('Stop');
|
|
127
|
+
stopButton.click();
|
|
128
|
+
expect(handleSignOutAction).toHaveBeenCalledWith({ returnTo });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not call getOrganizationAction when organizationId is not provided', () => {
|
|
132
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
133
|
+
impersonator: { email: 'admin@example.com' },
|
|
134
|
+
user: { id: '123', email: 'user@example.com' },
|
|
135
|
+
organizationId: null,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
render(<Impersonation />);
|
|
139
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should not call getOrganizationAction when impersonator is not present', () => {
|
|
143
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
144
|
+
impersonator: null,
|
|
145
|
+
user: { id: '123', email: 'user@example.com' },
|
|
146
|
+
organizationId: 'org_123',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
render(<Impersonation />);
|
|
150
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should not call getOrganizationAction when user is not present', () => {
|
|
154
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
155
|
+
impersonator: { email: 'admin@example.com' },
|
|
156
|
+
user: null,
|
|
157
|
+
organizationId: 'org_123',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render(<Impersonation />);
|
|
161
|
+
expect(getOrganizationAction).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should not call getOrganizationAction again when organization is already loaded with same ID', async () => {
|
|
165
|
+
const mockOrg = {
|
|
166
|
+
id: 'org_123',
|
|
167
|
+
name: 'Test Org',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
(getOrganizationAction as jest.Mock).mockResolvedValue(mockOrg);
|
|
171
|
+
|
|
172
|
+
const { rerender } = await act(async () => {
|
|
173
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
174
|
+
impersonator: { email: 'admin@example.com' },
|
|
175
|
+
user: { id: '123', email: 'user@example.com' },
|
|
176
|
+
organizationId: 'org_123',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return render(<Impersonation />);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Wait for the initial call to complete
|
|
183
|
+
await act(async () => {
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
188
|
+
|
|
189
|
+
// Rerender with the same organizationId
|
|
190
|
+
await act(async () => {
|
|
191
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
192
|
+
impersonator: { email: 'admin@example.com' },
|
|
193
|
+
user: { id: '123', email: 'user@example.com' },
|
|
194
|
+
organizationId: 'org_123',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
rerender(<Impersonation />);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Should still be called only once
|
|
201
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should call getOrganizationAction again when organizationId changes', async () => {
|
|
205
|
+
const mockOrg1 = {
|
|
206
|
+
id: 'org_123',
|
|
207
|
+
name: 'Test Org 1',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const mockOrg2 = {
|
|
211
|
+
id: 'org_456',
|
|
212
|
+
name: 'Test Org 2',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
(getOrganizationAction as jest.Mock).mockResolvedValueOnce(mockOrg1).mockResolvedValueOnce(mockOrg2);
|
|
216
|
+
|
|
217
|
+
const { rerender } = await act(async () => {
|
|
218
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
219
|
+
impersonator: { email: 'admin@example.com' },
|
|
220
|
+
user: { id: '123', email: 'user@example.com' },
|
|
221
|
+
organizationId: 'org_123',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return render(<Impersonation />);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Wait for the initial call to complete
|
|
228
|
+
await act(async () => {
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(1);
|
|
233
|
+
expect(getOrganizationAction).toHaveBeenCalledWith('org_123');
|
|
234
|
+
|
|
235
|
+
// Rerender with a different organizationId
|
|
236
|
+
await act(async () => {
|
|
237
|
+
(useAuth as jest.Mock).mockReturnValue({
|
|
238
|
+
impersonator: { email: 'admin@example.com' },
|
|
239
|
+
user: { id: '123', email: 'user@example.com' },
|
|
240
|
+
organizationId: 'org_456',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
rerender(<Impersonation />);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Should be called again with the new ID
|
|
247
|
+
expect(getOrganizationAction).toHaveBeenCalledTimes(2);
|
|
248
|
+
expect(getOrganizationAction).toHaveBeenCalledWith('org_456');
|
|
133
249
|
});
|
|
134
250
|
});
|
|
@@ -9,19 +9,21 @@ import { useAuth } from './authkit-provider.js';
|
|
|
9
9
|
|
|
10
10
|
interface ImpersonationProps extends React.ComponentPropsWithoutRef<'div'> {
|
|
11
11
|
side?: 'top' | 'bottom';
|
|
12
|
+
returnTo?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps) {
|
|
15
|
-
const { user, impersonator, organizationId
|
|
15
|
+
export function Impersonation({ side = 'bottom', returnTo, ...props }: ImpersonationProps) {
|
|
16
|
+
const { user, impersonator, organizationId } = useAuth();
|
|
16
17
|
|
|
17
18
|
const [organization, setOrganization] = React.useState<Organization | null>(null);
|
|
18
19
|
|
|
19
20
|
React.useEffect(() => {
|
|
20
|
-
if (!organizationId) return;
|
|
21
|
+
if (!organizationId || !impersonator || !user) return;
|
|
22
|
+
if (organization && organization.id === organizationId) return;
|
|
21
23
|
getOrganizationAction(organizationId).then(setOrganization);
|
|
22
|
-
}, [organizationId]);
|
|
24
|
+
}, [organizationId, impersonator, user]);
|
|
23
25
|
|
|
24
|
-
if (
|
|
26
|
+
if (!impersonator || !user) return null;
|
|
25
27
|
|
|
26
28
|
return (
|
|
27
29
|
<div
|
|
@@ -78,7 +80,7 @@ export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps)
|
|
|
78
80
|
<form
|
|
79
81
|
onSubmit={async (event) => {
|
|
80
82
|
event.preventDefault();
|
|
81
|
-
await handleSignOutAction();
|
|
83
|
+
await handleSignOutAction({ returnTo });
|
|
82
84
|
}}
|
|
83
85
|
style={{
|
|
84
86
|
display: 'flex',
|
|
@@ -12,13 +12,21 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
|
|
|
12
12
|
redirectUri = headersList.get('x-redirect-uri'),
|
|
13
13
|
loginHint,
|
|
14
14
|
prompt,
|
|
15
|
+
state: customState,
|
|
15
16
|
} = options;
|
|
16
17
|
|
|
18
|
+
const internalState = returnPathname
|
|
19
|
+
? btoa(JSON.stringify({ returnPathname })).replace(/\+/g, '-').replace(/\//g, '_')
|
|
20
|
+
: null;
|
|
21
|
+
|
|
22
|
+
const finalState =
|
|
23
|
+
internalState && customState ? `${internalState}.${customState}` : internalState || customState || undefined;
|
|
24
|
+
|
|
17
25
|
return getWorkOS().userManagement.getAuthorizationUrl({
|
|
18
26
|
provider: 'authkit',
|
|
19
27
|
clientId: WORKOS_CLIENT_ID,
|
|
20
28
|
redirectUri: redirectUri ?? WORKOS_REDIRECT_URI,
|
|
21
|
-
state:
|
|
29
|
+
state: finalState,
|
|
22
30
|
screenHint,
|
|
23
31
|
organizationId,
|
|
24
32
|
loginHint,
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './aut
|
|
|
2
2
|
import { handleAuth } from './authkit-callback-route.js';
|
|
3
3
|
import { authkit, authkitMiddleware } from './middleware.js';
|
|
4
4
|
import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
|
|
5
|
+
import { validateApiKey } from './validate-api-key.js';
|
|
5
6
|
import { getWorkOS } from './workos.js';
|
|
6
7
|
|
|
7
8
|
export * from './interfaces.js';
|
|
@@ -19,4 +20,5 @@ export {
|
|
|
19
20
|
switchToOrganization,
|
|
20
21
|
withAuth,
|
|
21
22
|
getTokenClaims,
|
|
23
|
+
validateApiKey,
|
|
22
24
|
};
|
package/src/interfaces.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface HandleAuthSuccessData extends Session {
|
|
|
12
12
|
oauthTokens?: OauthTokens;
|
|
13
13
|
organizationId?: string;
|
|
14
14
|
authenticationMethod?: AuthenticationResponse['authenticationMethod'];
|
|
15
|
+
state?: string | undefined;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface Impersonator {
|
|
@@ -67,6 +68,7 @@ export interface GetAuthURLOptions {
|
|
|
67
68
|
redirectUri?: string;
|
|
68
69
|
loginHint?: string;
|
|
69
70
|
prompt?: 'consent';
|
|
71
|
+
state?: string;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export interface AuthkitMiddlewareAuth {
|
package/src/session.spec.ts
CHANGED
|
@@ -146,7 +146,12 @@ describe('session.ts', () => {
|
|
|
146
146
|
|
|
147
147
|
await withAuth({ ensureSignedIn: true });
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
// URL-safe base64 encoding
|
|
150
|
+
const pathname = encodeURIComponent(
|
|
151
|
+
btoa(JSON.stringify({ returnPathname: '/protected?test=123' }))
|
|
152
|
+
.replace(/\+/g, '-')
|
|
153
|
+
.replace(/\//g, '_'),
|
|
154
|
+
);
|
|
150
155
|
|
|
151
156
|
expect(redirect).toHaveBeenCalledWith(expect.stringContaining(pathname));
|
|
152
157
|
});
|
package/src/test-helpers.ts
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { validateApiKey } from './validate-api-key.js';
|
|
4
|
+
import { getWorkOS } from './workos.js';
|
|
5
|
+
|
|
6
|
+
// These are mocked in jest.setup.ts
|
|
7
|
+
import { headers } from 'next/headers';
|
|
8
|
+
|
|
9
|
+
const workos = getWorkOS();
|
|
10
|
+
|
|
11
|
+
describe('validate-api-key.ts', () => {
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
// Clear all mocks between tests
|
|
14
|
+
jest.clearAllMocks();
|
|
15
|
+
|
|
16
|
+
const nextHeaders = await headers();
|
|
17
|
+
// @ts-expect-error - _reset is part of the mock
|
|
18
|
+
nextHeaders._reset();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('validateApiKey', () => {
|
|
22
|
+
it('should return valid API key when Bearer token is present and valid', async () => {
|
|
23
|
+
const mockApiKeyResponse = {
|
|
24
|
+
apiKey: {
|
|
25
|
+
id: 'api_key_123',
|
|
26
|
+
object: 'api_key' as const,
|
|
27
|
+
name: 'Test API Key',
|
|
28
|
+
obfuscatedValue: 'sk_…7890',
|
|
29
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
30
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
31
|
+
lastUsedAt: '2024-01-01T00:00:00Z',
|
|
32
|
+
permissions: [],
|
|
33
|
+
owner: { type: 'organization' as const, id: 'org_123' },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
jest.spyOn(workos.apiKeys, 'validateApiKey').mockResolvedValue(mockApiKeyResponse);
|
|
38
|
+
|
|
39
|
+
const nextHeaders = await headers();
|
|
40
|
+
nextHeaders.set('authorization', 'Bearer sk_test_1234567890');
|
|
41
|
+
|
|
42
|
+
const result = await validateApiKey();
|
|
43
|
+
|
|
44
|
+
expect(workos.apiKeys.validateApiKey).toHaveBeenCalledWith({
|
|
45
|
+
value: 'sk_test_1234567890',
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual(mockApiKeyResponse);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should return { apiKey: null } when no authorization header is present', async () => {
|
|
51
|
+
// Don't set any authorization header
|
|
52
|
+
const result = await validateApiKey();
|
|
53
|
+
|
|
54
|
+
expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
|
|
55
|
+
expect(result).toEqual({ apiKey: null });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return { apiKey: null } when authorization header is empty', async () => {
|
|
59
|
+
const nextHeaders = await headers();
|
|
60
|
+
nextHeaders.set('authorization', '');
|
|
61
|
+
|
|
62
|
+
const result = await validateApiKey();
|
|
63
|
+
|
|
64
|
+
expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
|
|
65
|
+
expect(result).toEqual({ apiKey: null });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return { apiKey: null } when authorization header does not start with Bearer', async () => {
|
|
69
|
+
const nextHeaders = await headers();
|
|
70
|
+
nextHeaders.set('authorization', 'Basic dXNlcjpwYXNz');
|
|
71
|
+
|
|
72
|
+
const result = await validateApiKey();
|
|
73
|
+
|
|
74
|
+
expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
|
|
75
|
+
expect(result).toEqual({ apiKey: null });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should return { apiKey: null } when Bearer token is missing', async () => {
|
|
79
|
+
const nextHeaders = await headers();
|
|
80
|
+
nextHeaders.set('authorization', 'Bearer');
|
|
81
|
+
|
|
82
|
+
const result = await validateApiKey();
|
|
83
|
+
|
|
84
|
+
expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
|
|
85
|
+
expect(result).toEqual({ apiKey: null });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return { apiKey: null } when Bearer token is only whitespace', async () => {
|
|
89
|
+
const nextHeaders = await headers();
|
|
90
|
+
nextHeaders.set('authorization', 'Bearer ');
|
|
91
|
+
|
|
92
|
+
const result = await validateApiKey();
|
|
93
|
+
|
|
94
|
+
expect(workos.apiKeys.validateApiKey).not.toHaveBeenCalled();
|
|
95
|
+
expect(result).toEqual({ apiKey: null });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return { apiKey: null } when WorkOS validation fails', async () => {
|
|
99
|
+
const mockResponse = { apiKey: null };
|
|
100
|
+
jest.spyOn(workos.apiKeys, 'validateApiKey').mockResolvedValue(mockResponse);
|
|
101
|
+
|
|
102
|
+
const nextHeaders = await headers();
|
|
103
|
+
nextHeaders.set('authorization', 'Bearer invalid_key');
|
|
104
|
+
|
|
105
|
+
const result = await validateApiKey();
|
|
106
|
+
|
|
107
|
+
expect(workos.apiKeys.validateApiKey).toHaveBeenCalledWith({
|
|
108
|
+
value: 'invalid_key',
|
|
109
|
+
});
|
|
110
|
+
expect(result).toEqual({ apiKey: null });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { getWorkOS } from './workos.js';
|
|
4
|
+
import { headers } from 'next/headers';
|
|
5
|
+
|
|
6
|
+
export async function validateApiKey() {
|
|
7
|
+
const headersList = await headers();
|
|
8
|
+
const authorizationHeader = headersList.get('authorization');
|
|
9
|
+
if (!authorizationHeader) {
|
|
10
|
+
return { apiKey: null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const value = authorizationHeader.match(/Bearer\s+(.*)/i)?.[1];
|
|
14
|
+
if (!value) {
|
|
15
|
+
return { apiKey: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return getWorkOS().apiKeys.validateApiKey({ value });
|
|
19
|
+
}
|
package/src/workos.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { WorkOS } from '@workos-inc/node';
|
|
|
2
2
|
import { WORKOS_API_HOSTNAME, WORKOS_API_KEY, WORKOS_API_HTTPS, WORKOS_API_PORT } from './env-variables.js';
|
|
3
3
|
import { lazy } from './utils.js';
|
|
4
4
|
|
|
5
|
-
export const VERSION = '2.
|
|
5
|
+
export const VERSION = '2.11.0';
|
|
6
6
|
|
|
7
7
|
const options = {
|
|
8
8
|
apiHostname: WORKOS_API_HOSTNAME,
|