@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.
Files changed (37) hide show
  1. package/README.md +107 -16
  2. package/dist/esm/auth.js +13 -5
  3. package/dist/esm/auth.js.map +1 -1
  4. package/dist/esm/authkit-callback-route.js +37 -2
  5. package/dist/esm/authkit-callback-route.js.map +1 -1
  6. package/dist/esm/components/impersonation.js +8 -6
  7. package/dist/esm/components/impersonation.js.map +1 -1
  8. package/dist/esm/get-authorization-url.js +6 -2
  9. package/dist/esm/get-authorization-url.js.map +1 -1
  10. package/dist/esm/index.js +2 -1
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/test-helpers.js +1 -0
  13. package/dist/esm/test-helpers.js.map +1 -1
  14. package/dist/esm/types/auth.d.ts +4 -2
  15. package/dist/esm/types/components/impersonation.d.ts +2 -1
  16. package/dist/esm/types/index.d.ts +2 -1
  17. package/dist/esm/types/interfaces.d.ts +2 -0
  18. package/dist/esm/types/validate-api-key.d.ts +1 -0
  19. package/dist/esm/types/workos.d.ts +1 -1
  20. package/dist/esm/validate-api-key.js +17 -0
  21. package/dist/esm/validate-api-key.js.map +1 -0
  22. package/dist/esm/workos.js +1 -1
  23. package/dist/esm/workos.js.map +1 -1
  24. package/package.json +4 -4
  25. package/src/auth.ts +29 -5
  26. package/src/authkit-callback-route.spec.ts +69 -0
  27. package/src/authkit-callback-route.ts +35 -2
  28. package/src/components/impersonation.spec.tsx +136 -20
  29. package/src/components/impersonation.tsx +8 -6
  30. package/src/get-authorization-url.ts +9 -1
  31. package/src/index.ts +2 -0
  32. package/src/interfaces.ts +2 -0
  33. package/src/session.spec.ts +6 -1
  34. package/src/test-helpers.ts +1 -0
  35. package/src/validate-api-key.spec.ts +113 -0
  36. package/src/validate-api-key.ts +19 -0
  37. 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
- }: { organizationId?: string; loginHint?: string; redirectUri?: string; prompt?: 'consent' } = {}) {
19
- return getAuthorizationUrl({ organizationId, screenHint: 'sign-in', loginHint, redirectUri, prompt });
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
- }: { organizationId?: string; loginHint?: string; redirectUri?: string; prompt?: 'consent' } = {}) {
28
- return getAuthorizationUrl({ organizationId, screenHint: 'sign-up', loginHint, redirectUri, prompt });
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
- revalidateTag(tag);
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
- let returnPathname = state && state !== 'null' ? JSON.parse(atob(state)).returnPathname : null;
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 = returnPathname ?? returnPathnameOption;
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).toHaveBeenCalled();
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, loading } = useAuth();
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 (loading || !impersonator || !user) return null;
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: returnPathname ? btoa(JSON.stringify({ returnPathname })) : undefined,
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 {
@@ -146,7 +146,12 @@ describe('session.ts', () => {
146
146
 
147
147
  await withAuth({ ensureSignedIn: true });
148
148
 
149
- const pathname = encodeURIComponent(btoa(JSON.stringify({ returnPathname: '/protected?test=123' })));
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
  });
@@ -45,6 +45,7 @@ export async function generateSession(overrides: Partial<User> = {}) {
45
45
  lastSignInAt: '2024-01-01T00:00:00Z',
46
46
  externalId: null,
47
47
  metadata: {},
48
+ locale: null,
48
49
  ...overrides,
49
50
  } satisfies User;
50
51
 
@@ -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.9.0';
5
+ export const VERSION = '2.11.0';
6
6
 
7
7
  const options = {
8
8
  apiHostname: WORKOS_API_HOSTNAME,