@workos-inc/authkit-nextjs 3.0.0-beta.1 → 3.0.1

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 (106) hide show
  1. package/README.md +305 -102
  2. package/dist/esm/actions.js +35 -5
  3. package/dist/esm/actions.js.map +1 -1
  4. package/dist/esm/auth.js +71 -21
  5. package/dist/esm/auth.js.map +1 -1
  6. package/dist/esm/authkit-callback-route.js +90 -92
  7. package/dist/esm/authkit-callback-route.js.map +1 -1
  8. package/dist/esm/components/authkit-provider.js +36 -15
  9. package/dist/esm/components/authkit-provider.js.map +1 -1
  10. package/dist/esm/components/impersonation.js +17 -15
  11. package/dist/esm/components/impersonation.js.map +1 -1
  12. package/dist/esm/components/min-max-button.js +1 -1
  13. package/dist/esm/components/min-max-button.js.map +1 -1
  14. package/dist/esm/components/tokenStore.js +28 -19
  15. package/dist/esm/components/tokenStore.js.map +1 -1
  16. package/dist/esm/components/useAccessToken.js +1 -1
  17. package/dist/esm/components/useAccessToken.js.map +1 -1
  18. package/dist/esm/components/useTokenClaims.js +1 -1
  19. package/dist/esm/components/useTokenClaims.js.map +1 -1
  20. package/dist/esm/cookie.js +20 -5
  21. package/dist/esm/cookie.js.map +1 -1
  22. package/dist/esm/env-variables.js +6 -6
  23. package/dist/esm/env-variables.js.map +1 -1
  24. package/dist/esm/errors.js +36 -0
  25. package/dist/esm/errors.js.map +1 -0
  26. package/dist/esm/get-authorization-url.js +51 -12
  27. package/dist/esm/get-authorization-url.js.map +1 -1
  28. package/dist/esm/index.js +5 -2
  29. package/dist/esm/index.js.map +1 -1
  30. package/dist/esm/interfaces.js +7 -1
  31. package/dist/esm/interfaces.js.map +1 -1
  32. package/dist/esm/middleware-helpers.js +102 -0
  33. package/dist/esm/middleware-helpers.js.map +1 -0
  34. package/dist/esm/middleware.js +3 -1
  35. package/dist/esm/middleware.js.map +1 -1
  36. package/dist/esm/pkce.js +52 -0
  37. package/dist/esm/pkce.js.map +1 -0
  38. package/dist/esm/session.js +82 -35
  39. package/dist/esm/session.js.map +1 -1
  40. package/dist/esm/test-helpers.js +1 -1
  41. package/dist/esm/test-helpers.js.map +1 -1
  42. package/dist/esm/types/actions.d.ts +34 -5
  43. package/dist/esm/types/auth.d.ts +7 -15
  44. package/dist/esm/types/components/authkit-provider.d.ts +6 -2
  45. package/dist/esm/types/components/impersonation.d.ts +2 -1
  46. package/dist/esm/types/cookie.d.ts +9 -0
  47. package/dist/esm/types/env-variables.d.ts +2 -1
  48. package/dist/esm/types/errors.d.ts +15 -0
  49. package/dist/esm/types/get-authorization-url.d.ts +2 -2
  50. package/dist/esm/types/index.d.ts +5 -2
  51. package/dist/esm/types/interfaces.d.ts +12 -0
  52. package/dist/esm/types/jwt.d.ts +9 -9
  53. package/dist/esm/types/middleware-helpers.d.ts +27 -0
  54. package/dist/esm/types/middleware.d.ts +3 -1
  55. package/dist/esm/types/pkce.d.ts +17 -0
  56. package/dist/esm/types/session.d.ts +1 -1
  57. package/dist/esm/types/utils.d.ts +5 -0
  58. package/dist/esm/types/validate-api-key.d.ts +1 -0
  59. package/dist/esm/types/workos.d.ts +1 -1
  60. package/dist/esm/utils.js +10 -2
  61. package/dist/esm/utils.js.map +1 -1
  62. package/dist/esm/validate-api-key.js +16 -0
  63. package/dist/esm/validate-api-key.js.map +1 -0
  64. package/dist/esm/workos.js +1 -1
  65. package/package.json +33 -34
  66. package/src/actions.spec.ts +91 -18
  67. package/src/actions.ts +44 -6
  68. package/src/auth.spec.ts +79 -29
  69. package/src/auth.ts +74 -42
  70. package/src/authkit-callback-route.spec.ts +372 -58
  71. package/src/authkit-callback-route.ts +121 -103
  72. package/src/components/authkit-provider.spec.tsx +264 -70
  73. package/src/components/authkit-provider.tsx +40 -15
  74. package/src/components/button.spec.tsx +4 -6
  75. package/src/components/impersonation.spec.tsx +152 -35
  76. package/src/components/impersonation.tsx +37 -30
  77. package/src/components/min-max-button.spec.tsx +2 -1
  78. package/src/components/tokenStore.spec.ts +59 -44
  79. package/src/components/tokenStore.ts +11 -3
  80. package/src/components/useAccessToken.spec.tsx +82 -83
  81. package/src/components/useTokenClaims.spec.tsx +23 -22
  82. package/src/cookie.spec.ts +63 -9
  83. package/src/cookie.ts +35 -0
  84. package/src/env-variables.ts +2 -0
  85. package/src/errors.spec.ts +108 -0
  86. package/src/errors.ts +46 -0
  87. package/src/get-authorization-url.spec.ts +170 -15
  88. package/src/get-authorization-url.ts +69 -23
  89. package/src/index.ts +20 -2
  90. package/src/interfaces.ts +15 -0
  91. package/src/jwt.ts +9 -9
  92. package/src/middleware-helpers.spec.ts +238 -0
  93. package/src/middleware-helpers.ts +134 -0
  94. package/src/middleware.spec.ts +25 -0
  95. package/src/middleware.ts +4 -1
  96. package/src/pkce.spec.ts +146 -0
  97. package/src/pkce.ts +59 -0
  98. package/src/session.spec.ts +87 -89
  99. package/src/session.ts +104 -27
  100. package/src/test-helpers.ts +1 -1
  101. package/src/utils.spec.ts +14 -31
  102. package/src/utils.ts +9 -0
  103. package/src/validate-api-key.spec.ts +111 -0
  104. package/src/validate-api-key.ts +19 -0
  105. package/src/workos.spec.ts +2 -2
  106. package/src/workos.ts +1 -1
package/src/auth.spec.ts CHANGED
@@ -1,57 +1,63 @@
1
- import { describe, it, expect, beforeEach, jest } from '@jest/globals';
2
-
3
1
  import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
4
2
  import * as session from './session.js';
5
3
  import * as cache from 'next/cache';
6
4
  import * as workosModule from './workos.js';
7
5
 
8
- // These are mocked in jest.setup.ts
6
+ // These are mocked in vitest.setup.ts
9
7
  import { cookies, headers } from 'next/headers';
10
8
  import { redirect } from 'next/navigation';
11
9
  import { generateSession, generateTestToken } from './test-helpers.js';
12
10
  import { sealData } from 'iron-session';
13
11
  import { getWorkOS } from './workos.js';
12
+ import { getStateFromPKCECookieValue } from './pkce.js';
14
13
 
15
14
  const workos = getWorkOS();
16
15
 
17
- jest.mock('next/cache', () => {
18
- const actual = jest.requireActual<typeof cache>('next/cache');
16
+ vi.mock('next/cache', async () => {
17
+ const actual = await vi.importActual<typeof cache>('next/cache');
19
18
  return {
20
19
  ...actual,
21
- revalidateTag: jest.fn(),
22
- revalidatePath: jest.fn(),
20
+ revalidateTag: vi.fn(),
21
+ revalidatePath: vi.fn(),
23
22
  };
24
23
  });
25
24
 
26
25
  // Create a fake WorkOS instance that will be used only in the "on error" tests
27
26
  const fakeWorkosInstance = {
28
27
  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(),
28
+ authenticateWithRefreshToken: vi.fn(),
29
+ getAuthorizationUrl: vi.fn(),
30
+ getJwksUrl: vi.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
31
+ getLogoutUrl: vi.fn(),
32
+ },
33
+ pkce: {
34
+ generate: vi.fn().mockResolvedValue({
35
+ codeVerifier: 'test-code-verifier',
36
+ codeChallenge: 'test-code-challenge',
37
+ codeChallengeMethod: 'S256' as const,
38
+ }),
33
39
  },
34
40
  };
35
41
 
36
- const revalidatePath = jest.mocked(cache.revalidatePath);
37
- const revalidateTag = jest.mocked(cache.revalidateTag);
42
+ const revalidatePath = vi.mocked(cache.revalidatePath);
43
+ const revalidateTag = vi.mocked(cache.revalidateTag);
38
44
  // We'll only use these in the "on error" tests
39
45
  const authenticateWithRefreshToken = fakeWorkosInstance.userManagement.authenticateWithRefreshToken;
40
46
  const getAuthorizationUrl = fakeWorkosInstance.userManagement.getAuthorizationUrl;
41
47
 
42
- jest.mock('../src/session', () => {
43
- const actual = jest.requireActual<typeof session>('../src/session');
48
+ vi.mock('../src/session', async () => {
49
+ const actual = await vi.importActual<typeof session>('../src/session');
44
50
 
45
51
  return {
46
52
  ...actual,
47
- refreshSession: jest.fn(actual.refreshSession),
53
+ refreshSession: vi.fn(actual.refreshSession),
48
54
  };
49
55
  });
50
56
 
51
57
  describe('auth.ts', () => {
52
58
  beforeEach(async () => {
53
59
  // Clear all mocks between tests
54
- jest.clearAllMocks();
60
+ vi.clearAllMocks();
55
61
 
56
62
  // Reset the cookie store
57
63
  const nextCookies = await cookies();
@@ -76,6 +82,15 @@ describe('auth.ts', () => {
76
82
  expect(url).toBeDefined();
77
83
  expect(() => new URL(url)).not.toThrow();
78
84
  });
85
+
86
+ it('should include returnTo as returnPathname in the state parameter', async () => {
87
+ const url = await getSignInUrl({ returnTo: '/dashboard' });
88
+ const parsedUrl = new URL(url);
89
+ const state = parsedUrl.searchParams.get('state');
90
+ expect(state).toBeDefined();
91
+ const decoded = await getStateFromPKCECookieValue(state!);
92
+ expect(decoded.returnPathname).toBe('/dashboard');
93
+ });
79
94
  });
80
95
 
81
96
  it('should not include prompt when not specified for getSignInUrl', async () => {
@@ -103,6 +118,15 @@ describe('auth.ts', () => {
103
118
  const url = await getSignUpUrl({ prompt: 'consent' });
104
119
  expect(url).toContain('prompt=consent');
105
120
  });
121
+
122
+ it('should include returnTo as returnPathname in the state parameter', async () => {
123
+ const url = await getSignUpUrl({ returnTo: '/welcome' });
124
+ const parsedUrl = new URL(url);
125
+ const state = parsedUrl.searchParams.get('state');
126
+ expect(state).toBeDefined();
127
+ const decoded = await getStateFromPKCECookieValue(state!);
128
+ expect(decoded.returnPathname).toBe('/welcome');
129
+ });
106
130
  });
107
131
 
108
132
  describe('switchToOrganization', () => {
@@ -135,14 +159,21 @@ describe('auth.ts', () => {
135
159
  nextHeaders.set('x-url', 'http://localhost/test');
136
160
  await generateSession();
137
161
 
162
+ fakeWorkosInstance.pkce.generate.mockResolvedValue({
163
+ codeVerifier: 'test-code-verifier',
164
+ codeChallenge: 'test-code-challenge',
165
+ codeChallengeMethod: 'S256' as const,
166
+ });
167
+
138
168
  // Create a WorkOS-like object that matches what our tests need
139
169
  const mockWorkOS = {
140
170
  userManagement: fakeWorkosInstance.userManagement,
171
+ pkce: fakeWorkosInstance.pkce,
141
172
  // Add minimal properties to satisfy TypeScript
142
- createHttpClient: jest.fn(),
143
- createWebhookClient: jest.fn(),
144
- createActionsClient: jest.fn(),
145
- createIronSessionProvider: jest.fn(),
173
+ createHttpClient: vi.fn(),
174
+ createWebhookClient: vi.fn(),
175
+ createActionsClient: vi.fn(),
176
+ createIronSessionProvider: vi.fn(),
146
177
  apiKey: 'test',
147
178
  clientId: 'test',
148
179
  host: 'test',
@@ -154,12 +185,12 @@ describe('auth.ts', () => {
154
185
 
155
186
  // Apply the mock for these tests only
156
187
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
- jest.spyOn(workosModule, 'getWorkOS').mockImplementation(() => mockWorkOS as any);
188
+ vi.spyOn(workosModule, 'getWorkOS').mockImplementation(() => mockWorkOS as any);
158
189
  });
159
190
 
160
191
  afterEach(() => {
161
192
  // Restore all mocks after each test
162
- jest.restoreAllMocks();
193
+ vi.restoreAllMocks();
163
194
  });
164
195
 
165
196
  it('should redirect to sign in when error is "sso_required"', async () => {
@@ -243,11 +274,30 @@ describe('auth.ts', () => {
243
274
  expect(sessionCookie).toBeUndefined();
244
275
  });
245
276
 
277
+ it('should clear lingering PKCE verifier cookies (legacy and per-flow)', async () => {
278
+ const nextCookies = await cookies();
279
+ const nextHeaders = await headers();
280
+
281
+ nextHeaders.set('x-workos-middleware', 'true');
282
+ nextCookies.set('wos-session', 'foo');
283
+ nextCookies.set('wos-auth-verifier', 'legacy-state');
284
+ nextCookies.set('wos-auth-verifier-a1b2c3d4', 'flow-a-state');
285
+ nextCookies.set('wos-auth-verifier-deadbeef', 'flow-b-state');
286
+ nextCookies.set('unrelated-cookie', 'keep-me');
287
+
288
+ await signOut();
289
+
290
+ expect(nextCookies.get('wos-auth-verifier')).toBeUndefined();
291
+ expect(nextCookies.get('wos-auth-verifier-a1b2c3d4')).toBeUndefined();
292
+ expect(nextCookies.get('wos-auth-verifier-deadbeef')).toBeUndefined();
293
+ expect(nextCookies.get('unrelated-cookie')?.value).toBe('keep-me');
294
+ });
295
+
246
296
  describe('when given a `returnTo` parameter', () => {
247
297
  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');
298
+ vi.spyOn(workos.userManagement, 'getLogoutUrl').mockReturnValue(
299
+ 'https://user-management-logout.com/signed-out',
300
+ );
251
301
  const mockSession = {
252
302
  accessToken: await generateTestToken(),
253
303
  sessionId: 'session_123',
@@ -306,9 +356,9 @@ describe('auth.ts', () => {
306
356
 
307
357
  nextCookies.set('wos-session', encryptedSession);
308
358
 
309
- jest
310
- .spyOn(workos.userManagement, 'getLogoutUrl')
311
- .mockReturnValue('https://api.workos.com/user_management/sessions/logout?session_id=session_123');
359
+ vi.spyOn(workos.userManagement, 'getLogoutUrl').mockReturnValue(
360
+ 'https://api.workos.com/user_management/sessions/logout?session_id=session_123',
361
+ );
312
362
 
313
363
  await signOut();
314
364
 
package/src/auth.ts CHANGED
@@ -5,41 +5,47 @@ import { revalidatePath, revalidateTag } from 'next/cache';
5
5
  import { cookies, headers } from 'next/headers';
6
6
  import { redirect } from 'next/navigation';
7
7
  import { WORKOS_COOKIE_NAME } from './env-variables.js';
8
- import { getCookieOptions } from './cookie.js';
8
+ import { getCookieOptions, getPKCECookieOptions } from './cookie.js';
9
9
  import { getAuthorizationUrl } from './get-authorization-url.js';
10
- import type { AccessToken, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
10
+ import type { AccessToken, GetAuthURLOptions, SwitchToOrganizationOptions, UserInfo } from './interfaces.js';
11
+ import { PKCE_COOKIE_NAME, setPKCECookie } from './pkce.js';
11
12
  import { getSessionFromCookie, refreshSession, withAuth } from './session.js';
12
13
  import { getWorkOS } from './workos.js';
13
- export async function getSignInUrl({
14
- organizationId,
15
- loginHint,
16
- redirectUri,
17
- prompt,
18
- state,
19
- }: {
20
- organizationId?: string;
21
- loginHint?: string;
22
- redirectUri?: string;
23
- prompt?: 'consent';
24
- state?: string;
25
- } = {}) {
26
- return getAuthorizationUrl({ organizationId, screenHint: 'sign-in', loginHint, redirectUri, prompt, state });
14
+
15
+ /**
16
+ * A wrapper around revalidateTag to provide compatibility with previous versions.
17
+ * @param tag The tag to revalidate.
18
+ */
19
+ function revalidateTagCompat(tag: string): void {
20
+ const fn = revalidateTag as (tag: string, profile: string) => void;
21
+ return fn(tag, 'max');
22
+ }
23
+
24
+ async function getAuthURLAndSetPKCECookie(options: GetAuthURLOptions): Promise<string> {
25
+ const { url, sealedState } = await getAuthorizationUrl(options);
26
+ await setPKCECookie(sealedState);
27
+
28
+ return url;
29
+ }
30
+
31
+ type GetSignUrlOptions = Omit<GetAuthURLOptions, 'screenHint' | 'returnPathname'> & {
32
+ returnTo?: string;
33
+ };
34
+
35
+ export async function getSignInUrl(authUrlOptions: GetSignUrlOptions = {}) {
36
+ return getAuthURLAndSetPKCECookie({
37
+ ...authUrlOptions,
38
+ returnPathname: authUrlOptions.returnTo,
39
+ screenHint: 'sign-in',
40
+ });
27
41
  }
28
42
 
29
- export async function getSignUpUrl({
30
- organizationId,
31
- loginHint,
32
- redirectUri,
33
- prompt,
34
- state,
35
- }: {
36
- organizationId?: string;
37
- loginHint?: string;
38
- redirectUri?: string;
39
- prompt?: 'consent';
40
- state?: string;
41
- } = {}) {
42
- return getAuthorizationUrl({ organizationId, screenHint: 'sign-up', loginHint, redirectUri, prompt, state });
43
+ export async function getSignUpUrl(authUrlOptions: GetSignUrlOptions = {}) {
44
+ return getAuthURLAndSetPKCECookie({
45
+ ...authUrlOptions,
46
+ returnPathname: authUrlOptions.returnTo,
47
+ screenHint: 'sign-up',
48
+ });
43
49
  }
44
50
 
45
51
  /**
@@ -67,7 +73,30 @@ export async function signOut({ returnTo }: { returnTo?: string } = {}) {
67
73
  const nextCookies = await cookies();
68
74
  const cookieName = WORKOS_COOKIE_NAME || 'wos-session';
69
75
  const { domain, path, sameSite, secure } = getCookieOptions();
70
- nextCookies.delete({ name: cookieName, domain, path, sameSite, secure });
76
+ try {
77
+ nextCookies.delete({ name: cookieName, domain, path, sameSite, secure });
78
+ } catch {
79
+ // Some environments (e.g., vinext) only accept a string cookie name
80
+ nextCookies.delete(cookieName);
81
+ }
82
+
83
+ // Clear any lingering PKCE verifier cookies so orphans from abandoned
84
+ // flows don't accumulate toward HTTP 431 or confuse future sign-ins.
85
+ const pkceOptions = getPKCECookieOptions();
86
+ for (const { name } of nextCookies.getAll()) {
87
+ if (!name.startsWith(PKCE_COOKIE_NAME)) continue;
88
+ try {
89
+ nextCookies.delete({
90
+ name,
91
+ domain: pkceOptions.domain,
92
+ path: pkceOptions.path,
93
+ sameSite: pkceOptions.sameSite,
94
+ secure: pkceOptions.secure,
95
+ });
96
+ } catch {
97
+ nextCookies.delete(name);
98
+ }
99
+ }
71
100
 
72
101
  if (sessionId) {
73
102
  redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }));
@@ -98,22 +127,25 @@ export async function switchToOrganization(
98
127
  redirect(cause.rawData.authkit_redirect_url);
99
128
  } else {
100
129
  if (cause?.error === 'sso_required' || cause?.error === 'mfa_enrollment') {
101
- const url = await getAuthorizationUrl({ organizationId });
102
- return redirect(url);
130
+ return redirect(await getAuthURLAndSetPKCECookie({ organizationId }));
103
131
  }
104
132
  throw error;
105
133
  }
106
134
  }
107
135
 
108
- switch (revalidationStrategy) {
109
- case 'path':
110
- revalidatePath(pathname);
111
- break;
112
- case 'tag':
113
- for (const tag of revalidationTags) {
114
- revalidateTag(tag);
115
- }
116
- break;
136
+ try {
137
+ switch (revalidationStrategy) {
138
+ case 'path':
139
+ revalidatePath(pathname);
140
+ break;
141
+ case 'tag':
142
+ for (const tag of revalidationTags) {
143
+ revalidateTagCompat(tag);
144
+ }
145
+ break;
146
+ }
147
+ } catch {
148
+ // revalidatePath/revalidateTag may not be available in non-Next.js environments (e.g., vinext)
117
149
  }
118
150
  if (revalidationStrategy !== 'none') {
119
151
  redirect(pathname);