@workos-inc/authkit-nextjs 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +124 -29
  2. package/dist/esm/auth.js +18 -5
  3. package/dist/esm/auth.js.map +1 -1
  4. package/dist/esm/components/tokenStore.js +110 -11
  5. package/dist/esm/components/tokenStore.js.map +1 -1
  6. package/dist/esm/components/useAccessToken.js +34 -4
  7. package/dist/esm/components/useAccessToken.js.map +1 -1
  8. package/dist/esm/cookie.js +51 -0
  9. package/dist/esm/cookie.js.map +1 -1
  10. package/dist/esm/get-authorization-url.js +2 -1
  11. package/dist/esm/get-authorization-url.js.map +1 -1
  12. package/dist/esm/middleware.js +2 -2
  13. package/dist/esm/middleware.js.map +1 -1
  14. package/dist/esm/session.js +36 -3
  15. package/dist/esm/session.js.map +1 -1
  16. package/dist/esm/test-helpers.js +57 -0
  17. package/dist/esm/test-helpers.js.map +1 -0
  18. package/dist/esm/types/auth.d.ts +5 -3
  19. package/dist/esm/types/components/tokenStore.d.ts +7 -2
  20. package/dist/esm/types/cookie.d.ts +1 -0
  21. package/dist/esm/types/interfaces.d.ts +3 -0
  22. package/dist/esm/types/middleware.d.ts +1 -1
  23. package/dist/esm/types/session.d.ts +2 -1
  24. package/dist/esm/types/test-helpers.d.ts +3 -0
  25. package/dist/esm/types/workos.d.ts +1 -1
  26. package/dist/esm/workos.js +1 -1
  27. package/package.json +5 -4
  28. package/src/actions.spec.ts +100 -0
  29. package/src/auth.spec.ts +347 -0
  30. package/src/auth.ts +19 -6
  31. package/src/authkit-callback-route.spec.ts +258 -0
  32. package/src/components/authkit-provider.spec.tsx +471 -0
  33. package/src/components/button.spec.tsx +46 -0
  34. package/src/components/impersonation.spec.tsx +134 -0
  35. package/src/components/min-max-button.spec.tsx +60 -0
  36. package/src/components/tokenStore.spec.ts +816 -0
  37. package/src/components/tokenStore.ts +147 -12
  38. package/src/components/useAccessToken.spec.tsx +731 -0
  39. package/src/components/useAccessToken.ts +40 -6
  40. package/src/components/useTokenClaims.spec.tsx +194 -0
  41. package/src/cookie.spec.ts +276 -0
  42. package/src/cookie.ts +56 -0
  43. package/src/get-authorization-url.spec.ts +60 -0
  44. package/src/get-authorization-url.ts +2 -0
  45. package/src/interfaces.ts +3 -0
  46. package/src/jwt.spec.ts +159 -0
  47. package/src/middleware.ts +2 -1
  48. package/src/session.spec.ts +1152 -0
  49. package/src/session.ts +42 -2
  50. package/src/test-helpers.ts +70 -0
  51. package/src/utils.spec.ts +142 -0
  52. package/src/workos.spec.ts +67 -0
  53. package/src/workos.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { useAuth } from './authkit-provider.js';
3
3
  import { tokenStore } from './tokenStore.js';
4
4
 
@@ -42,9 +42,22 @@ export function useAccessToken(): UseAccessTokenReturn {
42
42
 
43
43
  const tokenState = useSyncExternalStore(tokenStore.subscribe, tokenStore.getSnapshot, tokenStore.getServerSnapshot);
44
44
 
45
+ // Track if we're waiting for the initial token fetch for the current user
46
+ // Initialize synchronously to prevent first-paint flash
47
+ const [isInitialTokenLoading, setIsInitialTokenLoading] = useState(() => {
48
+ // Only show loading if we have a user but no token yet
49
+ return Boolean(user && !tokenState.token && !tokenState.error);
50
+ });
51
+
45
52
  useEffect(() => {
46
53
  if (!user) {
47
- tokenStore.clearToken();
54
+ setIsInitialTokenLoading(false);
55
+ // Clear token when user logs out
56
+ if (prevUserIdRef.current !== undefined) {
57
+ tokenStore.clearToken();
58
+ }
59
+ prevUserIdRef.current = undefined;
60
+ prevSessionRef.current = undefined;
48
61
  return;
49
62
  }
50
63
 
@@ -59,10 +72,28 @@ export function useAccessToken(): UseAccessTokenReturn {
59
72
  prevSessionRef.current = sessionId;
60
73
  prevUserIdRef.current = userId;
61
74
 
75
+ // Check if getAccessTokenSilently will actually fetch (not just return cached)
76
+ const currentToken = tokenStore.getSnapshot().token;
77
+ const tokenData = currentToken ? tokenStore.parseToken(currentToken) : null;
78
+ const willActuallyFetch = !currentToken || (tokenData && tokenData.isExpiring);
79
+
80
+ // Only show loading if we're actually going to fetch
81
+ if (willActuallyFetch) {
82
+ setIsInitialTokenLoading(true);
83
+ }
84
+
62
85
  /* istanbul ignore next */
63
- tokenStore.getAccessTokenSilently().catch(() => {
64
- // Error is handled in the store
65
- });
86
+ tokenStore
87
+ .getAccessTokenSilently()
88
+ .catch(() => {
89
+ // Error is handled in the store
90
+ })
91
+ .finally(() => {
92
+ // Only clear loading if we were actually loading
93
+ if (willActuallyFetch) {
94
+ setIsInitialTokenLoading(false);
95
+ }
96
+ });
66
97
  }, [userId, sessionId]);
67
98
 
68
99
  useEffect(() => {
@@ -112,9 +143,12 @@ export function useAccessToken(): UseAccessTokenReturn {
112
143
  return tokenStore.refreshToken();
113
144
  }, []);
114
145
 
146
+ // Combine loading states: initial token fetch OR token store is loading
147
+ const isLoading = isInitialTokenLoading || tokenState.loading;
148
+
115
149
  return {
116
150
  accessToken: tokenState.token,
117
- loading: tokenState.loading,
151
+ loading: isLoading,
118
152
  error: tokenState.error,
119
153
  refresh,
120
154
  getAccessToken,
@@ -0,0 +1,194 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { useAuth } from './authkit-provider.js';
5
+
6
+ jest.mock('../actions.js', () => ({
7
+ getAccessTokenAction: jest.fn(),
8
+ refreshAccessTokenAction: jest.fn(),
9
+ }));
10
+
11
+ jest.mock('./authkit-provider.js', () => {
12
+ const originalModule = jest.requireActual('./authkit-provider.js');
13
+ return {
14
+ ...originalModule,
15
+ useAuth: jest.fn(),
16
+ };
17
+ });
18
+
19
+ jest.mock('./useAccessToken.js', () => ({
20
+ useAccessToken: jest.fn(() => ({ accessToken: undefined })),
21
+ }));
22
+
23
+ jest.mock('jose', () => ({
24
+ decodeJwt: jest.fn((token: string) => {
25
+ if (token === 'malformed-token' || token === 'throw-error-token') {
26
+ throw new Error('Invalid JWT');
27
+ }
28
+ try {
29
+ const parts = token.split('.');
30
+ if (parts.length !== 3) throw new Error('Invalid JWT');
31
+ const payload = JSON.parse(atob(parts[1]));
32
+ return payload;
33
+ } catch {
34
+ throw new Error('Invalid JWT');
35
+ }
36
+ }),
37
+ }));
38
+
39
+ // Import after mocks are set up
40
+ import { useAccessToken } from './useAccessToken.js';
41
+ import { useTokenClaims } from './useTokenClaims.js';
42
+
43
+ describe('useTokenClaims', () => {
44
+ beforeEach(() => {
45
+ jest.clearAllMocks();
46
+ jest.useFakeTimers();
47
+
48
+ (useAuth as jest.Mock).mockImplementation(() => ({
49
+ user: { id: 'user_123' },
50
+ sessionId: 'session_123',
51
+ refreshAuth: jest.fn().mockResolvedValue({}),
52
+ }));
53
+
54
+ // Reset useAccessToken mock to default
55
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: undefined });
56
+ });
57
+
58
+ afterEach(() => {
59
+ jest.useRealTimers();
60
+ });
61
+
62
+ const TokenClaimsTestComponent = () => {
63
+ const tokenClaims = useTokenClaims();
64
+ return (
65
+ <div>
66
+ <div data-testid="claims">{JSON.stringify(tokenClaims)}</div>
67
+ </div>
68
+ );
69
+ };
70
+
71
+ it('should return empty object when no access token is available', async () => {
72
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: undefined });
73
+
74
+ const { getByTestId } = render(<TokenClaimsTestComponent />);
75
+
76
+ await waitFor(() => {
77
+ expect(getByTestId('claims')).toHaveTextContent('{}');
78
+ });
79
+ });
80
+
81
+ it('should return all token claims when access token is available', async () => {
82
+ const payload = {
83
+ aud: 'audience',
84
+ exp: 9999999999,
85
+ iat: 1234567800,
86
+ iss: 'issuer',
87
+ sub: 'user_123',
88
+ sid: 'session_123',
89
+ org_id: 'org_123',
90
+ role: 'admin',
91
+ permissions: ['read', 'write'],
92
+ entitlements: ['feature_a'],
93
+ feature_flags: ['device-authorization-grant'],
94
+ jti: 'jwt_123',
95
+ nbf: 1234567800,
96
+ // Custom claims
97
+ customField1: 'value1',
98
+ customField2: 42,
99
+ customObject: { nested: 'data' },
100
+ };
101
+ const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
102
+
103
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
104
+
105
+ const { getByTestId } = render(<TokenClaimsTestComponent />);
106
+
107
+ await waitFor(() => {
108
+ expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
109
+ });
110
+ });
111
+
112
+ it('should return all standard claims when token has only standard claims', async () => {
113
+ const payload = {
114
+ aud: 'audience',
115
+ exp: 9999999999,
116
+ iat: 1234567800,
117
+ iss: 'issuer',
118
+ sub: 'user_123',
119
+ sid: 'session_123',
120
+ org_id: 'org_123',
121
+ role: 'admin',
122
+ permissions: ['read', 'write'],
123
+ entitlements: ['feature_a'],
124
+ feature_flags: ['device-authorization-grant'],
125
+ jti: 'jwt_123',
126
+ nbf: 1234567800,
127
+ };
128
+ const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
129
+
130
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
131
+
132
+ const { getByTestId } = render(<TokenClaimsTestComponent />);
133
+
134
+ await waitFor(() => {
135
+ expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
136
+ });
137
+ });
138
+
139
+ it('should handle partial claims', async () => {
140
+ const payload = {
141
+ sub: 'user_123',
142
+ exp: 9999999999,
143
+ customField: 'value',
144
+ anotherCustom: true,
145
+ };
146
+ const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
147
+
148
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
149
+
150
+ const { getByTestId } = render(<TokenClaimsTestComponent />);
151
+
152
+ await waitFor(() => {
153
+ expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
154
+ });
155
+ });
156
+
157
+ it('should handle complex nested claims', async () => {
158
+ const payload = {
159
+ sub: 'user_123',
160
+ exp: 9999999999,
161
+ metadata: {
162
+ preferences: {
163
+ theme: 'dark',
164
+ language: 'en',
165
+ },
166
+ settings: ['setting1', 'setting2'],
167
+ },
168
+ tags: ['tag1', 'tag2'],
169
+ permissions_custom: {
170
+ read: true,
171
+ write: false,
172
+ },
173
+ };
174
+ const token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
175
+
176
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: token });
177
+
178
+ const { getByTestId } = render(<TokenClaimsTestComponent />);
179
+
180
+ await waitFor(() => {
181
+ expect(getByTestId('claims')).toHaveTextContent(JSON.stringify(payload));
182
+ });
183
+ });
184
+
185
+ it('should return empty object when decodeJwt throws an error', async () => {
186
+ (useAccessToken as jest.Mock).mockReturnValue({ accessToken: 'malformed-token' });
187
+
188
+ const { getByTestId } = render(<TokenClaimsTestComponent />);
189
+
190
+ await waitFor(() => {
191
+ expect(getByTestId('claims')).toHaveTextContent('{}');
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect } from '@jest/globals';
2
+
3
+ // Mock at the top of the file
4
+ jest.mock('./env-variables');
5
+
6
+ describe('cookie.ts', () => {
7
+ beforeEach(() => {
8
+ // Clear all mocks before each test
9
+ jest.clearAllMocks();
10
+ // Reset modules
11
+ jest.resetModules();
12
+ });
13
+
14
+ describe('getCookieOptions', () => {
15
+ it('should return the default cookie options', async () => {
16
+ const { getCookieOptions } = await import('./cookie');
17
+
18
+ const options = getCookieOptions();
19
+ expect(options).toEqual(
20
+ expect.objectContaining({
21
+ path: '/',
22
+ httpOnly: true,
23
+ secure: false,
24
+ sameSite: 'lax',
25
+ maxAge: 400 * 24 * 60 * 60,
26
+ domain: 'example.com',
27
+ }),
28
+ );
29
+ });
30
+
31
+ it('should return the cookie options with custom values', async () => {
32
+ // Import the mocked module
33
+ const envVars = await import('./env-variables');
34
+
35
+ // Set the mock values
36
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: '1000' });
37
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: 'foobar.com' });
38
+
39
+ const { getCookieOptions } = await import('./cookie');
40
+ const options = getCookieOptions('http://example.com');
41
+
42
+ expect(options).toEqual(
43
+ expect.objectContaining({
44
+ secure: false,
45
+ maxAge: 1000,
46
+ domain: 'foobar.com',
47
+ }),
48
+ );
49
+
50
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' });
51
+
52
+ const options2 = getCookieOptions('http://example.com');
53
+ expect(options2).toEqual(
54
+ expect.objectContaining({
55
+ secure: false,
56
+ maxAge: 1000,
57
+ domain: '',
58
+ }),
59
+ );
60
+
61
+ const options3 = getCookieOptions('https://example.com', true);
62
+ // Domain should not be included when WORKOS_COOKIE_DOMAIN is empty
63
+ expect(options3).toEqual(expect.not.stringContaining('Domain='));
64
+ });
65
+
66
+ it('should return the cookie options with expired set to true', async () => {
67
+ const { getCookieOptions } = await import('./cookie');
68
+ const options = getCookieOptions('http://example.com', false, true);
69
+ expect(options).toEqual(expect.objectContaining({ maxAge: 0 }));
70
+ });
71
+
72
+ it('should return the cookie options as a string', async () => {
73
+ const { getCookieOptions } = await import('./cookie');
74
+ const options = getCookieOptions('http://example.com', true, false);
75
+ expect(options).toEqual(expect.stringContaining('HttpOnly; SameSite=Lax; Max-Age=34560000; Domain=example.com'));
76
+ expect(options).toEqual(expect.not.stringContaining('Secure'));
77
+
78
+ const options2 = getCookieOptions('https://example.com', true, true);
79
+ expect(options2).toEqual(expect.stringContaining('HttpOnly'));
80
+ expect(options2).toEqual(expect.stringContaining('Secure'));
81
+ expect(options2).toEqual(expect.stringContaining('SameSite=Lax'));
82
+ expect(options2).toEqual(expect.stringContaining('Max-Age=0'));
83
+ expect(options2).toEqual(expect.stringContaining('Domain=example.com'));
84
+ });
85
+
86
+ it('allows the sameSite config to be set by the WORKOS_COOKIE_SAMESITE env variable', async () => {
87
+ const envVars = await import('./env-variables');
88
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'none' });
89
+
90
+ const { getCookieOptions } = await import('./cookie');
91
+ const options = getCookieOptions('http://example.com');
92
+ expect(options).toEqual(expect.objectContaining({ sameSite: 'none' }));
93
+ });
94
+
95
+ it('throws an error if the sameSite value is invalid', async () => {
96
+ const envVars = await import('./env-variables');
97
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'invalid' });
98
+
99
+ const { getCookieOptions } = await import('./cookie');
100
+ expect(() => getCookieOptions('http://example.com')).toThrow('Invalid SameSite value: invalid');
101
+ });
102
+
103
+ it('defaults to secure=true when no URL is available', async () => {
104
+ const envVars = await import('./env-variables');
105
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: undefined });
106
+
107
+ const { getCookieOptions } = await import('./cookie');
108
+ const options = getCookieOptions();
109
+ expect(options).toEqual(expect.objectContaining({ secure: true }));
110
+ });
111
+
112
+ it('defaults to secure=true when no URL is available with lax sameSite', async () => {
113
+ const envVars = await import('./env-variables');
114
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: undefined });
115
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_SAMESITE', { value: 'lax' });
116
+
117
+ const { getCookieOptions } = await import('./cookie');
118
+ const options = getCookieOptions();
119
+ expect(options).toEqual(expect.objectContaining({ secure: true, sameSite: 'lax' }));
120
+ });
121
+
122
+ it('handles invalid URLs gracefully by defaulting to secure=true', async () => {
123
+ const { getCookieOptions } = await import('./cookie');
124
+ const options = getCookieOptions('not-a-valid-url');
125
+ expect(options).toEqual(expect.objectContaining({ secure: true }));
126
+ });
127
+
128
+ it('handles invalid WORKOS_COOKIE_MAX_AGE gracefully', async () => {
129
+ const envVars = await import('./env-variables');
130
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_MAX_AGE', { value: 'invalid-number' });
131
+
132
+ const { getCookieOptions } = await import('./cookie');
133
+ const options = getCookieOptions();
134
+ expect(options).toEqual(expect.objectContaining({ maxAge: 34560000 })); // Falls back to default
135
+ });
136
+
137
+ it('properly formats cookie string without Domain when not set', async () => {
138
+ const envVars = await import('./env-variables');
139
+ Object.defineProperty(envVars, 'WORKOS_COOKIE_DOMAIN', { value: '' });
140
+
141
+ const { getCookieOptions } = await import('./cookie');
142
+ const cookieString = getCookieOptions('https://example.com', true);
143
+ expect(cookieString).not.toContain('Domain=');
144
+ expect(cookieString).toContain('Secure');
145
+ expect(cookieString).toContain('SameSite=Lax'); // Capitalized
146
+ });
147
+ });
148
+
149
+ describe('getJwtCookie', () => {
150
+ beforeEach(() => {
151
+ // Reset NODE_ENV for each test
152
+ delete process.env.NODE_ENV;
153
+ });
154
+
155
+ it('should create JWT cookie with Secure flag for HTTPS URLs', async () => {
156
+ const { getJwtCookie } = await import('./cookie');
157
+
158
+ const cookie = getJwtCookie('test-token', 'https://example.com');
159
+
160
+ expect(cookie).toBe('workos-access-token=test-token; SameSite=Lax; Max-Age=30; Secure');
161
+ });
162
+
163
+ it('should create JWT cookie without Secure flag for HTTP URLs', async () => {
164
+ const { getJwtCookie } = await import('./cookie');
165
+
166
+ const cookie = getJwtCookie('test-token', 'http://localhost:3000');
167
+
168
+ expect(cookie).toBe('workos-access-token=test-token; SameSite=Lax; Max-Age=30');
169
+ });
170
+
171
+ it('should force Secure in production except for localhost', async () => {
172
+ process.env.NODE_ENV = 'production';
173
+
174
+ const { getJwtCookie } = await import('./cookie');
175
+
176
+ // Production with regular domain should be secure
177
+ const prodCookie = getJwtCookie('prod-token', 'http://example.com');
178
+ expect(prodCookie).toContain('Secure');
179
+
180
+ // Production with localhost should not be secure
181
+ const localhostCookie = getJwtCookie('local-token', 'http://localhost:3000');
182
+ expect(localhostCookie).not.toContain('Secure');
183
+ });
184
+
185
+ it('should handle invalid URLs with no fallback URL', async () => {
186
+ process.env.NODE_ENV = 'production';
187
+
188
+ // Mock no WORKOS_REDIRECT_URI
189
+ const envVars = await import('./env-variables');
190
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: '' });
191
+
192
+ const { getJwtCookie } = await import('./cookie');
193
+
194
+ const cookie = getJwtCookie('token', 'invalid-url');
195
+
196
+ expect(cookie).toContain('Secure'); // Should default to secure in production when no fallback
197
+ });
198
+
199
+ it('should fall back to WORKOS_REDIRECT_URI when invalid URL provided', async () => {
200
+ const envVars = await import('./env-variables');
201
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'https://app.workos.com/callback' });
202
+
203
+ const { getJwtCookie } = await import('./cookie');
204
+
205
+ const cookie = getJwtCookie('token', 'invalid-url');
206
+
207
+ expect(cookie).toContain('Secure'); // Should use HTTPS from fallback URL
208
+ });
209
+
210
+ it('should set secure to false when WORKOS_REDIRECT_URI parsing fails', async () => {
211
+ process.env.NODE_ENV = 'development'; // Not production
212
+
213
+ const envVars = await import('./env-variables');
214
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'also-invalid-url' });
215
+
216
+ const { getJwtCookie } = await import('./cookie');
217
+
218
+ const cookie = getJwtCookie('token', null); // This triggers the WORKOS_REDIRECT_URI path
219
+
220
+ expect(cookie).not.toContain('Secure'); // Should be false when URL parsing fails (line 128)
221
+ });
222
+
223
+ it('should handle both main URL and fallback URL parsing failures', async () => {
224
+ const envVars = await import('./env-variables');
225
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'invalid-fallback-url' });
226
+
227
+ const { getJwtCookie } = await import('./cookie');
228
+
229
+ // Invalid main URL with invalid fallback URL - should hit line 118
230
+ const cookie = getJwtCookie('token', 'invalid-main-url');
231
+
232
+ expect(cookie).not.toContain('Secure'); // Line 118: secure = false when fallback parsing fails
233
+ });
234
+
235
+ it('should use WORKOS_REDIRECT_URI when no URL provided', async () => {
236
+ const envVars = await import('./env-variables');
237
+ Object.defineProperty(envVars, 'WORKOS_REDIRECT_URI', { value: 'https://secure.example.com' });
238
+
239
+ const { getJwtCookie } = await import('./cookie');
240
+
241
+ const cookie = getJwtCookie('token', null);
242
+
243
+ expect(cookie).toContain('Secure'); // Should use HTTPS from WORKOS_REDIRECT_URI
244
+ });
245
+
246
+ it('should create expired JWT cookie for deletion', async () => {
247
+ const { getJwtCookie } = await import('./cookie');
248
+
249
+ const cookie = getJwtCookie('token', 'https://example.com', true);
250
+
251
+ expect(cookie).toBe(
252
+ 'workos-access-token=; SameSite=Lax; Max-Age=0; Secure; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
253
+ );
254
+ });
255
+
256
+ it('should handle null token body', async () => {
257
+ const { getJwtCookie } = await import('./cookie');
258
+
259
+ const cookie = getJwtCookie(null, 'https://example.com');
260
+
261
+ expect(cookie).toBe('workos-access-token=; SameSite=Lax; Max-Age=30; Secure');
262
+ });
263
+
264
+ it('should handle localhost vs 127.0.0.1 in production', async () => {
265
+ process.env.NODE_ENV = 'production';
266
+
267
+ const { getJwtCookie } = await import('./cookie');
268
+
269
+ const localhostCookie = getJwtCookie('token', 'http://localhost:3000');
270
+ const ipCookie = getJwtCookie('token', 'http://127.0.0.1:3000');
271
+
272
+ expect(localhostCookie).not.toContain('Secure');
273
+ expect(ipCookie).not.toContain('Secure');
274
+ });
275
+ });
276
+ });
package/src/cookie.ts CHANGED
@@ -8,6 +8,9 @@ import { CookieOptions } from './interfaces.js';
8
8
 
9
9
  type ValidSameSite = CookieOptions['sameSite'];
10
10
 
11
+ const JWT_COOKIE_MAX_AGE = 30; // seconds
12
+ const JWT_COOKIE_NAME = 'workos-access-token';
13
+
11
14
  function assertValidSamSite(sameSite: string): asserts sameSite is ValidSameSite {
12
15
  if (!['lax', 'strict', 'none'].includes(sameSite.toLowerCase())) {
13
16
  throw new Error(`Invalid SameSite value: ${sameSite}`);
@@ -88,3 +91,56 @@ export function getCookieOptions(
88
91
  domain: WORKOS_COOKIE_DOMAIN || '',
89
92
  };
90
93
  }
94
+
95
+ export function getJwtCookie(body: string | null, requestUrlOrRedirectUri?: string | null, expired?: boolean): string {
96
+ const cookie = `${JWT_COOKIE_NAME}=${expired ? '' : (body ?? '')}`;
97
+
98
+ // Force Secure in production, except for localhost
99
+ let secure = false;
100
+ const isProduction = process.env.NODE_ENV === 'production';
101
+
102
+ if (requestUrlOrRedirectUri) {
103
+ try {
104
+ const url = new URL(requestUrlOrRedirectUri);
105
+ const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
106
+ // In production, always use Secure unless explicitly on localhost
107
+ secure = isProduction ? !isLocalhost : url.protocol === 'https:';
108
+ } catch {
109
+ // If URL parsing fails, default to secure in production
110
+ secure = isProduction;
111
+ // If it's not a valid URL, fall back to WORKOS_REDIRECT_URI
112
+ const fallbackUrl = WORKOS_REDIRECT_URI;
113
+ if (fallbackUrl) {
114
+ try {
115
+ const url = new URL(fallbackUrl);
116
+ secure = url.protocol === 'https:';
117
+ } catch {
118
+ secure = false;
119
+ }
120
+ }
121
+ }
122
+ } else if (WORKOS_REDIRECT_URI) {
123
+ // No URL provided, check WORKOS_REDIRECT_URI
124
+ try {
125
+ const url = new URL(WORKOS_REDIRECT_URI);
126
+ secure = url.protocol === 'https:';
127
+ } catch {
128
+ secure = false;
129
+ }
130
+ }
131
+
132
+ const maxAge = expired ? 0 : JWT_COOKIE_MAX_AGE;
133
+
134
+ const parts = [cookie, 'SameSite=Lax', `Max-Age=${maxAge}`];
135
+
136
+ // Only add Secure flag if on HTTPS
137
+ if (secure) {
138
+ parts.push('Secure');
139
+ }
140
+
141
+ if (expired) {
142
+ parts.push(`Expires=${new Date(0).toUTCString()}`);
143
+ }
144
+
145
+ return parts.join('; ');
146
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, beforeEach, jest } from '@jest/globals';
2
+ import { getAuthorizationUrl } from './get-authorization-url.js';
3
+ import { headers } from 'next/headers';
4
+ import { getWorkOS } from './workos.js';
5
+
6
+ jest.mock('next/headers');
7
+
8
+ // Mock dependencies
9
+ const fakeWorkosInstance = {
10
+ userManagement: {
11
+ getAuthorizationUrl: jest.fn(),
12
+ },
13
+ };
14
+
15
+ jest.mock('./workos', () => ({
16
+ getWorkOS: jest.fn(() => fakeWorkosInstance),
17
+ }));
18
+
19
+ describe('getAuthorizationUrl', () => {
20
+ const workos = getWorkOS();
21
+ beforeEach(() => {
22
+ jest.clearAllMocks();
23
+ });
24
+
25
+ it('uses x-redirect-uri header when redirectUri option is not provided', async () => {
26
+ const nextHeaders = await headers();
27
+ nextHeaders.set('x-redirect-uri', 'http://test-redirect.com');
28
+
29
+ // Mock workos.userManagement.getAuthorizationUrl
30
+ jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
31
+
32
+ await getAuthorizationUrl({});
33
+
34
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
35
+ expect.objectContaining({
36
+ redirectUri: 'http://test-redirect.com',
37
+ }),
38
+ );
39
+ });
40
+
41
+ it('works when called with no arguments', async () => {
42
+ jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
43
+
44
+ await getAuthorizationUrl(); // Call with no arguments
45
+
46
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalled();
47
+ });
48
+
49
+ it('works when prompt is provided', async () => {
50
+ jest.mocked(workos.userManagement.getAuthorizationUrl).mockReturnValue('mock-url');
51
+
52
+ await getAuthorizationUrl({ prompt: 'consent' });
53
+
54
+ expect(workos.userManagement.getAuthorizationUrl).toHaveBeenCalledWith(
55
+ expect.objectContaining({
56
+ prompt: 'consent',
57
+ }),
58
+ );
59
+ });
60
+ });
@@ -11,6 +11,7 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
11
11
  organizationId,
12
12
  redirectUri = headersList.get('x-redirect-uri'),
13
13
  loginHint,
14
+ prompt,
14
15
  } = options;
15
16
 
16
17
  return getWorkOS().userManagement.getAuthorizationUrl({
@@ -21,6 +22,7 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
21
22
  screenHint,
22
23
  organizationId,
23
24
  loginHint,
25
+ prompt,
24
26
  });
25
27
  }
26
28