@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
@@ -1,14 +1,13 @@
1
- import { describe, it, expect } from '@jest/globals';
2
-
3
- // Mock at the top of the file
4
- jest.mock('./env-variables');
5
-
6
1
  describe('cookie.ts', () => {
7
2
  beforeEach(() => {
8
3
  // Clear all mocks before each test
9
- jest.clearAllMocks();
10
- // Reset modules
11
- jest.resetModules();
4
+ vi.clearAllMocks();
5
+ // Reset modules to ensure fresh imports
6
+ vi.resetModules();
7
+ // Re-mock env-variables with a fresh copy each time
8
+ vi.doMock('./env-variables', async (importOriginal) => {
9
+ return { ...(await importOriginal<typeof import('./env-variables')>()) };
10
+ });
12
11
  });
13
12
 
14
13
  describe('getCookieOptions', () => {
@@ -147,11 +146,17 @@ describe('cookie.ts', () => {
147
146
  });
148
147
 
149
148
  describe('getJwtCookie', () => {
149
+ const originalEnv = process.env;
150
+
150
151
  beforeEach(() => {
151
- // Reset NODE_ENV for each test
152
+ process.env = { ...originalEnv };
152
153
  delete process.env.NODE_ENV;
153
154
  });
154
155
 
156
+ afterEach(() => {
157
+ process.env = originalEnv;
158
+ });
159
+
155
160
  it('should create JWT cookie with Secure flag for HTTPS URLs', async () => {
156
161
  const { getJwtCookie } = await import('./cookie');
157
162
 
@@ -273,4 +278,53 @@ describe('cookie.ts', () => {
273
278
  expect(ipCookie).not.toContain('Secure');
274
279
  });
275
280
  });
281
+
282
+ describe('getPKCECookieOptions', () => {
283
+ it('should use 10-minute max-age, not the session cookie max-age', async () => {
284
+ const { getPKCECookieOptions } = await import('./cookie');
285
+
286
+ const options = getPKCECookieOptions();
287
+
288
+ expect(options).toEqual(expect.objectContaining({ maxAge: 600 }));
289
+ });
290
+
291
+ it('should use 10-minute max-age in string format', async () => {
292
+ const { getPKCECookieOptions } = await import('./cookie');
293
+
294
+ const options = getPKCECookieOptions('http://localhost:3000', true);
295
+
296
+ expect(options).toContain('Max-Age=600');
297
+ expect(options).not.toContain('Max-Age=34560000');
298
+ });
299
+
300
+ it('should use max-age 0 when expired in object format', async () => {
301
+ const { getPKCECookieOptions } = await import('./cookie');
302
+
303
+ const options = getPKCECookieOptions(undefined, false, true);
304
+
305
+ expect(options).toEqual(expect.objectContaining({ maxAge: 0 }));
306
+ });
307
+
308
+ it('should use max-age 0 when expired in string format', async () => {
309
+ const { getPKCECookieOptions } = await import('./cookie');
310
+
311
+ const options = getPKCECookieOptions('http://localhost:3000', true, true);
312
+
313
+ expect(options).toContain('Max-Age=0');
314
+ });
315
+
316
+ it('should downgrade SameSite=Strict to Lax', async () => {
317
+ const envVars = await import('./env-variables');
318
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'strict' });
319
+
320
+ const { getPKCECookieOptions } = await import('./cookie');
321
+
322
+ const objectOptions = getPKCECookieOptions();
323
+ expect(objectOptions).toEqual(expect.objectContaining({ sameSite: 'lax' }));
324
+
325
+ const stringOptions = getPKCECookieOptions('http://localhost:3000', true);
326
+ expect(stringOptions).toContain('SameSite=Lax');
327
+ expect(stringOptions).not.toContain('SameSite=Strict');
328
+ });
329
+ });
276
330
  });
package/src/cookie.ts CHANGED
@@ -92,6 +92,41 @@ export function getCookieOptions(
92
92
  };
93
93
  }
94
94
 
95
+ const PKCE_COOKIE_MAX_AGE = 600; // 10 minutes
96
+
97
+ /**
98
+ * Cookie options for the PKCE verifier cookie.
99
+ * 'strict' blocks the cookie on the cross-site redirect back from WorkOS; downgrade to 'lax'.
100
+ * 'none' is more permissive and must be preserved for iframe/cross-origin embed flows.
101
+ * Max-age is always capped to 10 minutes — PKCE cookies are single-use and short-lived.
102
+ */
103
+ export function getPKCECookieOptions(): CookieOptions;
104
+ export function getPKCECookieOptions(redirectUri: string | null | undefined, asString: true, expired?: boolean): string;
105
+ export function getPKCECookieOptions(
106
+ redirectUri?: string | null,
107
+ asString?: boolean,
108
+ expired?: boolean,
109
+ ): CookieOptions | string;
110
+ export function getPKCECookieOptions(
111
+ redirectUri?: string | null,
112
+ asString: boolean = false,
113
+ expired: boolean = false,
114
+ ): CookieOptions | string {
115
+ if (asString) {
116
+ const options = getCookieOptions(redirectUri, true, expired);
117
+ return options
118
+ .replace(/SameSite=Strict/i, 'SameSite=Lax')
119
+ .replace(/Max-Age=\d+/, `Max-Age=${expired ? 0 : PKCE_COOKIE_MAX_AGE}`);
120
+ }
121
+
122
+ const options = getCookieOptions(redirectUri);
123
+ return {
124
+ ...options,
125
+ sameSite: options.sameSite.toLowerCase() === 'strict' ? 'lax' : options.sameSite,
126
+ maxAge: expired ? 0 : PKCE_COOKIE_MAX_AGE,
127
+ };
128
+ }
129
+
95
130
  export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: string | null, expired?: boolean): string {
96
131
  const cookie = `${JWT_COOKIE_NAME}=${expired ? '' : (body ?? '')}`;
97
132
 
@@ -12,6 +12,7 @@ const WORKOS_COOKIE_DOMAIN = getEnvVariable('WORKOS_COOKIE_DOMAIN');
12
12
  const WORKOS_COOKIE_MAX_AGE = getEnvVariable('WORKOS_COOKIE_MAX_AGE');
13
13
  const WORKOS_COOKIE_NAME = getEnvVariable('WORKOS_COOKIE_NAME');
14
14
  const WORKOS_COOKIE_SAMESITE = getEnvVariable('WORKOS_COOKIE_SAMESITE') as 'lax' | 'strict' | 'none' | undefined;
15
+ const WORKOS_CLAIM_TOKEN = getEnvVariable('WORKOS_CLAIM_TOKEN');
15
16
 
16
17
  // Required env variables
17
18
  const WORKOS_API_KEY = getEnvVariable('WORKOS_API_KEY') ?? '';
@@ -24,6 +25,7 @@ export {
24
25
  WORKOS_API_HTTPS,
25
26
  WORKOS_API_KEY,
26
27
  WORKOS_API_PORT,
28
+ WORKOS_CLAIM_TOKEN,
27
29
  WORKOS_CLIENT_ID,
28
30
  WORKOS_COOKIE_DOMAIN,
29
31
  WORKOS_COOKIE_MAX_AGE,
@@ -0,0 +1,108 @@
1
+ import { AuthKitError, TokenRefreshError, getSessionErrorContext } from './errors.js';
2
+ import type { Session } from './interfaces.js';
3
+ import type { User } from '@workos-inc/node';
4
+
5
+ describe('AuthKitError', () => {
6
+ it('creates error with message', () => {
7
+ const error = new AuthKitError('Test error');
8
+
9
+ expect(error.message).toBe('Test error');
10
+ expect(error.name).toBe('AuthKitError');
11
+ expect(error).toBeInstanceOf(Error);
12
+ });
13
+
14
+ it('creates error with cause and data', () => {
15
+ const originalError = new Error('Original error');
16
+ const data = { userId: '123' };
17
+ const error = new AuthKitError('Test error', originalError, data);
18
+
19
+ expect(error.cause).toBe(originalError);
20
+ expect(error.data).toEqual(data);
21
+ });
22
+ });
23
+
24
+ describe('TokenRefreshError', () => {
25
+ it('creates error with correct name and inheritance', () => {
26
+ const error = new TokenRefreshError('Refresh failed');
27
+
28
+ expect(error.name).toBe('TokenRefreshError');
29
+ expect(error.message).toBe('Refresh failed');
30
+ expect(error).toBeInstanceOf(AuthKitError);
31
+ expect(error).toBeInstanceOf(Error);
32
+ });
33
+
34
+ it('creates error with cause and context', () => {
35
+ const originalError = new Error('Network error');
36
+ const error = new TokenRefreshError('Refresh failed', originalError, {
37
+ userId: 'user_123',
38
+ sessionId: 'session_456',
39
+ });
40
+
41
+ expect(error.cause).toBe(originalError);
42
+ expect(error.userId).toBe('user_123');
43
+ expect(error.sessionId).toBe('session_456');
44
+ });
45
+
46
+ it('has undefined properties when no context provided', () => {
47
+ const error = new TokenRefreshError('Refresh failed');
48
+
49
+ expect(error.userId).toBeUndefined();
50
+ expect(error.sessionId).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe('getSessionErrorContext', () => {
55
+ function createTestJwt(payload: Record<string, unknown>): string {
56
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
57
+ const payloadStr = btoa(JSON.stringify(payload));
58
+ return `${header}.${payloadStr}.test-signature`;
59
+ }
60
+
61
+ it('returns empty object for missing session', () => {
62
+ expect(getSessionErrorContext(null)).toEqual({});
63
+ expect(getSessionErrorContext(undefined)).toEqual({});
64
+ });
65
+
66
+ it('extracts userId and sessionId from access token', () => {
67
+ const session: Session = {
68
+ accessToken: createTestJwt({ sub: 'user_456', sid: 'session_123' }),
69
+ refreshToken: 'refresh_token',
70
+ user: {
71
+ id: 'user_456',
72
+ email: 'test@example.com',
73
+ emailVerified: true,
74
+ profilePictureUrl: null,
75
+ firstName: null,
76
+ lastName: null,
77
+ createdAt: '2024-01-01',
78
+ updatedAt: '2024-01-01',
79
+ object: 'user',
80
+ } as User,
81
+ };
82
+
83
+ const context = getSessionErrorContext(session);
84
+ expect(context.userId).toBe('user_456');
85
+ expect(context.sessionId).toBe('session_123');
86
+ });
87
+
88
+ it('returns empty object for invalid JWT', () => {
89
+ const session: Session = {
90
+ accessToken: 'invalid-jwt',
91
+ refreshToken: 'refresh_token',
92
+ user: {
93
+ id: 'user_123',
94
+ email: 'test@example.com',
95
+ emailVerified: true,
96
+ profilePictureUrl: null,
97
+ firstName: null,
98
+ lastName: null,
99
+ createdAt: '2024-01-01',
100
+ updatedAt: '2024-01-01',
101
+ object: 'user',
102
+ } as User,
103
+ };
104
+
105
+ const context = getSessionErrorContext(session);
106
+ expect(context).toEqual({});
107
+ });
108
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { Session } from './interfaces.js';
2
+ import { decodeJwt } from './jwt.js';
3
+
4
+ export class AuthKitError extends Error {
5
+ data?: Record<string, unknown>;
6
+
7
+ constructor(message: string, cause?: unknown, data?: Record<string, unknown>) {
8
+ super(message);
9
+ this.name = 'AuthKitError';
10
+ this.cause = cause;
11
+ this.data = data;
12
+ }
13
+ }
14
+
15
+ export interface TokenRefreshErrorContext {
16
+ userId?: string;
17
+ sessionId?: string;
18
+ }
19
+
20
+ export class TokenRefreshError extends AuthKitError {
21
+ readonly userId?: string;
22
+ readonly sessionId?: string;
23
+
24
+ constructor(message: string, cause?: unknown, context?: TokenRefreshErrorContext) {
25
+ super(message, cause);
26
+ this.name = 'TokenRefreshError';
27
+ this.userId = context?.userId;
28
+ this.sessionId = context?.sessionId;
29
+ }
30
+ }
31
+
32
+ export function getSessionErrorContext(session?: Session | null): TokenRefreshErrorContext {
33
+ if (!session?.accessToken) {
34
+ return {};
35
+ }
36
+
37
+ try {
38
+ const { payload } = decodeJwt(session.accessToken);
39
+ return {
40
+ userId: payload.sub,
41
+ sessionId: payload.sid,
42
+ };
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
@@ -1,33 +1,45 @@
1
- import { describe, it, expect, beforeEach, jest } from '@jest/globals';
2
1
  import { getAuthorizationUrl } from './get-authorization-url.js';
3
2
  import { headers } from 'next/headers';
4
3
  import { getWorkOS } from './workos.js';
5
-
6
- jest.mock('next/headers');
4
+ import { getStateFromPKCECookieValue } from './pkce.js';
7
5
 
8
6
  // Mock dependencies
9
- const fakeWorkosInstance = {
10
- userManagement: {
11
- getAuthorizationUrl: jest.fn(),
7
+ const { fakeWorkosInstance } = vi.hoisted(() => ({
8
+ fakeWorkosInstance: {
9
+ baseURL: 'https://api.workos.com',
10
+ userManagement: {
11
+ getAuthorizationUrl: vi.fn(),
12
+ },
13
+ pkce: {
14
+ generate: vi.fn().mockResolvedValue({
15
+ codeVerifier: 'test-code-verifier',
16
+ codeChallenge: 'test-code-challenge',
17
+ codeChallengeMethod: 'S256' as const,
18
+ }),
19
+ },
12
20
  },
13
- };
21
+ }));
14
22
 
15
- jest.mock('./workos', () => ({
16
- getWorkOS: jest.fn(() => fakeWorkosInstance),
23
+ vi.mock('./workos', () => ({
24
+ getWorkOS: vi.fn(() => fakeWorkosInstance),
17
25
  }));
18
26
 
19
27
  describe('getAuthorizationUrl', () => {
20
28
  const workos = getWorkOS();
21
29
  beforeEach(() => {
22
- jest.clearAllMocks();
30
+ vi.clearAllMocks();
31
+ fakeWorkosInstance.pkce.generate.mockResolvedValue({
32
+ codeVerifier: 'test-code-verifier',
33
+ codeChallenge: 'test-code-challenge',
34
+ codeChallengeMethod: 'S256' as const,
35
+ });
23
36
  });
24
37
 
25
38
  it('uses x-redirect-uri header when redirectUri option is not provided', async () => {
26
39
  const nextHeaders = await headers();
27
40
  nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
28
41
 
29
- // Mock workos.userManagement.getAuthorizationUrl
30
- jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
42
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
31
43
 
32
44
  await getAuthorizationUrl({});
33
45
 
@@ -39,15 +51,15 @@ describe('getAuthorizationUrl', () => {
39
51
  });
40
52
 
41
53
  it('works when called with no arguments', async () => {
42
- jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
54
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
43
55
 
44
- await getAuthorizationUrl(); // Call with no arguments
56
+ await getAuthorizationUrl();
45
57
 
46
58
  expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled();
47
59
  });
48
60
 
49
61
  it('works when prompt is provided', async () => {
50
- jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
62
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
51
63
 
52
64
  await getAuthorizationUrl({ prompt: 'consent' });
53
65
 
@@ -57,4 +69,147 @@ describe('getAuthorizationUrl', () => {
57
69
  }),
58
70
  );
59
71
  });
72
+
73
+ describe('claim nonce', () => {
74
+ afterEach(() => {
75
+ delete process.env.WORKOS_CLAIM_TOKEN;
76
+ });
77
+
78
+ it('does not fetch nonce when WORKOS_CLAIM_TOKEN is not set', async () => {
79
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
80
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
81
+
82
+ await getAuthorizationUrl({});
83
+
84
+ expect(fetchSpy).not.toHaveBeenCalledWith(expect.stringContaining('claim-nonces'), expect.anything());
85
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
86
+ expect.not.objectContaining({ claimNonce: expect.any(String) }),
87
+ );
88
+ fetchSpy.mockRestore();
89
+ });
90
+
91
+ it('fetches nonce and passes claimNonce when WORKOS_CLAIM_TOKEN is set', async () => {
92
+ process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
93
+ const fetchSpy = vi
94
+ .spyOn(globalThis, 'fetch')
95
+ .mockResolvedValueOnce(new Response(JSON.stringify({ nonce: 'test-nonce' }), { status: 201 }));
96
+
97
+ vi.resetModules();
98
+ const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
99
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
100
+
101
+ await freshGetAuthorizationUrl({});
102
+
103
+ expect(fetchSpy).toHaveBeenCalledWith(
104
+ 'https://api.workos.com/x/one-shot-environments/claim-nonces',
105
+ expect.objectContaining({
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ client_id: 'client_1234567890', claim_token: 'test-claim-token' }),
109
+ }),
110
+ );
111
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
112
+ expect.objectContaining({ claimNonce: 'test-nonce' }),
113
+ );
114
+ fetchSpy.mockRestore();
115
+ });
116
+
117
+ it('proceeds without nonce on network error', async () => {
118
+ process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
119
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
120
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
121
+
122
+ vi.resetModules();
123
+ const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
124
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
125
+
126
+ await freshGetAuthorizationUrl({});
127
+
128
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
129
+ expect.not.objectContaining({ claimNonce: expect.any(String) }),
130
+ );
131
+ expect(warnSpy).toHaveBeenCalledWith(
132
+ '[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN. Try removing WORKOS_CLAIM_TOKEN from your environment variables.',
133
+ expect.any(Error),
134
+ );
135
+ fetchSpy.mockRestore();
136
+ warnSpy.mockRestore();
137
+ });
138
+
139
+ it('proceeds without nonce on non-OK response', async () => {
140
+ process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
141
+ const fetchSpy = vi
142
+ .spyOn(globalThis, 'fetch')
143
+ .mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
144
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
145
+
146
+ vi.resetModules();
147
+ const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
148
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
149
+
150
+ await freshGetAuthorizationUrl({});
151
+
152
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
153
+ expect.not.objectContaining({ claimNonce: expect.any(String) }),
154
+ );
155
+ expect(warnSpy).toHaveBeenCalledWith(
156
+ '[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN (401). Try removing WORKOS_CLAIM_TOKEN from your environment variables.',
157
+ );
158
+ fetchSpy.mockRestore();
159
+ warnSpy.mockRestore();
160
+ });
161
+
162
+ it('includes PKCE and claim nonce together', async () => {
163
+ process.env.WORKOS_CLAIM_TOKEN = 'test-claim-token';
164
+ const fetchSpy = vi
165
+ .spyOn(globalThis, 'fetch')
166
+ .mockResolvedValueOnce(new Response(JSON.stringify({ nonce: 'test-nonce' }), { status: 201 }));
167
+
168
+ vi.resetModules();
169
+ const { getAuthorizationUrl: freshGetAuthorizationUrl } = await import('./get-authorization-url.js');
170
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
171
+
172
+ const result = await freshGetAuthorizationUrl({});
173
+
174
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
175
+ expect.objectContaining({
176
+ claimNonce: 'test-nonce',
177
+ codeChallenge: 'test-code-challenge',
178
+ codeChallengeMethod: 'S256',
179
+ }),
180
+ );
181
+ expect(result.sealedState).toBeDefined();
182
+ fetchSpy.mockRestore();
183
+ });
184
+ });
185
+
186
+ describe('PKCE', () => {
187
+ it('always generates PKCE pair and includes code challenge', async () => {
188
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
189
+
190
+ const result = await getAuthorizationUrl({});
191
+
192
+ expect(fakeWorkosInstance.pkce.generate).toHaveBeenCalled();
193
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
194
+ expect.objectContaining({
195
+ codeChallenge: 'test-code-challenge',
196
+ codeChallengeMethod: 'S256',
197
+ }),
198
+ );
199
+ expect(result.url).toBe('mock-url');
200
+ expect(result.sealedState).toBeDefined();
201
+ expect(result.sealedState).not.toBe('');
202
+ });
203
+
204
+ it('seals codeVerifier and nonce into state', async () => {
205
+ vi.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
206
+
207
+ const result = await getAuthorizationUrl({});
208
+
209
+ const { codeVerifier, nonce } = await getStateFromPKCECookieValue(result.sealedState);
210
+ expect(codeVerifier).toBe('test-code-verifier');
211
+ expect(nonce).toBeDefined();
212
+ expect(typeof nonce).toBe('string');
213
+ });
214
+ });
60
215
  });
@@ -1,37 +1,83 @@
1
- import { getWorkOS } from './workos.js';
2
- import { WORKOS_CLIENT_ID, WORKOS_REDIRECT_URI } from './env-variables.js';
3
- import { GetAuthURLOptions } from './interfaces.js';
1
+ import { sealData } from 'iron-session';
4
2
  import { headers } from 'next/headers';
3
+ import { WORKOS_CLAIM_TOKEN, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from './env-variables.js';
4
+ import { GetAuthURLOptions, GetAuthURLResult, State } from './interfaces.js';
5
+ import { getWorkOS } from './workos.js';
5
6
 
6
- async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
7
- const headersList = await headers();
8
- const {
9
- returnPathname,
10
- screenHint,
11
- organizationId,
12
- redirectUri = headersList.get('x-redirect-uri'),
13
- loginHint,
14
- prompt,
15
- state: customState,
16
- } = options;
7
+ async function fetchClaimNonce(baseURL: string): Promise<string | null> {
8
+ try {
9
+ const response = await fetch(`${baseURL}/x/one-shot-environments/claim-nonces`, {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/json' },
12
+ body: JSON.stringify({
13
+ client_id: WORKOS_CLIENT_ID,
14
+ claim_token: WORKOS_CLAIM_TOKEN,
15
+ }),
16
+ });
17
+ if (!response.ok) {
18
+ if (response.status !== 409) {
19
+ console.warn(
20
+ `[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN (${response.status}). Try removing WORKOS_CLAIM_TOKEN from your environment variables.`,
21
+ );
22
+ }
23
+ return null;
24
+ }
25
+ const data = await response.json();
26
+ return data.nonce;
27
+ } catch (error) {
28
+ console.warn(
29
+ '[authkit-nextjs]: Failed to exchange WORKOS_CLAIM_TOKEN. Try removing WORKOS_CLAIM_TOKEN from your environment variables.',
30
+ error,
31
+ );
32
+ return null;
33
+ }
34
+ }
35
+
36
+ async function getAuthorizationUrl({
37
+ returnPathname,
38
+ screenHint,
39
+ organizationId,
40
+ loginHint,
41
+ prompt,
42
+ state: customState,
43
+ redirectUri,
44
+ }: GetAuthURLOptions = {}): Promise<GetAuthURLResult> {
45
+ const redirectUriToUse = await (async () => {
46
+ if (redirectUri) {
47
+ return redirectUri;
48
+ }
49
+
50
+ const headersList = await headers();
51
+ return headersList.get('x-redirect-uri') ?? undefined;
52
+ })();
17
53
 
18
- const internalState = returnPathname
19
- ? btoa(JSON.stringify({ returnPathname })).replace(/\+/g, '-').replace(/\//g, '_')
20
- : null;
54
+ const pkce = await getWorkOS().pkce.generate();
55
+ const claimNonce = WORKOS_CLAIM_TOKEN ? await fetchClaimNonce(getWorkOS().baseURL) : null;
21
56
 
22
- const finalState =
23
- internalState && customState ? `${internalState}.${customState}` : internalState || customState || undefined;
57
+ const state = {
58
+ nonce: crypto.randomUUID(),
59
+ codeVerifier: pkce.codeVerifier,
60
+ customState,
61
+ returnPathname,
62
+ } satisfies State;
63
+
64
+ const sealedState = await sealData(state, { password: WORKOS_COOKIE_PASSWORD, ttl: 600 });
24
65
 
25
- return getWorkOS().userManagement.getAuthorizationUrl({
26
- provider: 'authkit',
66
+ const url = getWorkOS().userManagement.getAuthorizationUrl({
67
+ provider: 'authkit' as const,
27
68
  clientId: WORKOS_CLIENT_ID,
28
- redirectUri: redirectUri ?? WORKOS_REDIRECT_URI,
29
- state: finalState,
69
+ redirectUri: redirectUriToUse ?? WORKOS_REDIRECT_URI,
30
70
  screenHint,
31
71
  organizationId,
32
72
  loginHint,
33
73
  prompt,
74
+ state: sealedState,
75
+ codeChallenge: pkce.codeChallenge,
76
+ codeChallengeMethod: pkce.codeChallengeMethod,
77
+ ...(claimNonce && { claimNonce }),
34
78
  });
79
+
80
+ return { url, sealedState };
35
81
  }
36
82
 
37
83
  export { getAuthorizationUrl };
package/src/index.ts CHANGED
@@ -1,22 +1,40 @@
1
1
  import { getSignInUrl, getSignUpUrl, signOut, switchToOrganization } from './auth.js';
2
2
  import { handleAuth } from './authkit-callback-route.js';
3
- import { authkit, authkitMiddleware } from './middleware.js';
3
+ import { AuthKitError, TokenRefreshError } from './errors.js';
4
+ import { authkit, authkitMiddleware, authkitProxy } from './middleware.js';
5
+ export {
6
+ applyResponseHeaders,
7
+ handleAuthkitHeaders,
8
+ handleAuthkitProxy,
9
+ partitionAuthkitHeaders,
10
+ isAuthkitRequestHeader,
11
+ AUTHKIT_REQUEST_HEADERS,
12
+ type AuthkitHeadersResult,
13
+ type AuthkitRedirectStatus,
14
+ type AuthkitRequestHeader,
15
+ type HandleAuthkitHeadersOptions,
16
+ } from './middleware-helpers.js';
4
17
  import { getTokenClaims, refreshSession, saveSession, withAuth } from './session.js';
18
+ import { validateApiKey } from './validate-api-key.js';
5
19
  import { getWorkOS } from './workos.js';
6
20
 
7
21
  export * from './interfaces.js';
8
22
 
9
23
  export {
24
+ AuthKitError,
25
+ TokenRefreshError,
10
26
  authkit,
11
27
  authkitMiddleware,
28
+ authkitProxy,
12
29
  getSignInUrl,
13
30
  getSignUpUrl,
31
+ getTokenClaims,
14
32
  getWorkOS,
15
33
  handleAuth,
16
34
  refreshSession,
17
35
  saveSession,
18
36
  signOut,
19
37
  switchToOrganization,
38
+ validateApiKey,
20
39
  withAuth,
21
- getTokenClaims,
22
40
  };