@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,3 +1,4 @@
1
+ import type { Mock } from 'vitest';
1
2
  import { render, act, screen } from '@testing-library/react';
2
3
  import '@testing-library/jest-dom';
3
4
  import { Impersonation } from './impersonation.js';
@@ -7,39 +8,26 @@ import * as React from 'react';
7
8
  import { handleSignOutAction } from '../actions.js';
8
9
 
9
10
  // Mock the useAuth hook
10
- jest.mock('./authkit-provider', () => ({
11
- useAuth: jest.fn(),
11
+ vi.mock('./authkit-provider', () => ({
12
+ useAuth: vi.fn(),
12
13
  }));
13
14
 
14
15
  // Mock the getOrganizationAction
15
- jest.mock('../actions', () => ({
16
- getOrganizationAction: jest.fn(),
17
- handleSignOutAction: jest.fn(),
16
+ vi.mock('../actions', () => ({
17
+ getOrganizationAction: vi.fn(),
18
+ handleSignOutAction: vi.fn(),
18
19
  }));
19
20
 
20
21
  describe('Impersonation', () => {
21
22
  beforeEach(() => {
22
- jest.clearAllMocks();
23
+ vi.clearAllMocks();
23
24
  });
24
25
 
25
26
  it('should return null if not impersonating', () => {
26
- (useAuth as jest.Mock).mockReturnValue({
27
+ (useAuth as Mock).mockReturnValue({
27
28
  impersonator: null,
28
29
  user: { id: '123', email: 'user@example.com' },
29
30
  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
31
  });
44
32
 
45
33
  const { container } = render(<Impersonation />);
@@ -47,11 +35,10 @@ describe('Impersonation', () => {
47
35
  });
48
36
 
49
37
  it('should render impersonation banner when impersonating', () => {
50
- (useAuth as jest.Mock).mockReturnValue({
38
+ (useAuth as Mock).mockReturnValue({
51
39
  impersonator: { email: 'admin@example.com' },
52
40
  user: { id: '123', email: 'user@example.com' },
53
41
  organizationId: null,
54
- loading: false,
55
42
  });
56
43
 
57
44
  const { container } = render(<Impersonation />);
@@ -59,14 +46,13 @@ describe('Impersonation', () => {
59
46
  });
60
47
 
61
48
  it('should render with organization info when organizationId is provided', async () => {
62
- (useAuth as jest.Mock).mockReturnValue({
49
+ (useAuth as Mock).mockReturnValue({
63
50
  impersonator: { email: 'admin@example.com' },
64
51
  user: { id: '123', email: 'user@example.com' },
65
52
  organizationId: 'org_123',
66
- loading: false,
67
53
  });
68
54
 
69
- (getOrganizationAction as jest.Mock).mockResolvedValue({
55
+ (getOrganizationAction as Mock).mockResolvedValue({
70
56
  id: 'org_123',
71
57
  name: 'Test Org',
72
58
  });
@@ -79,11 +65,10 @@ describe('Impersonation', () => {
79
65
  });
80
66
 
81
67
  it('should render at the bottom by default', () => {
82
- (useAuth as jest.Mock).mockReturnValue({
68
+ (useAuth as Mock).mockReturnValue({
83
69
  impersonator: { email: 'admin@example.com' },
84
70
  user: { id: '123', email: 'user@example.com' },
85
71
  organizationId: null,
86
- loading: false,
87
72
  });
88
73
 
89
74
  const { container } = render(<Impersonation />);
@@ -92,11 +77,10 @@ describe('Impersonation', () => {
92
77
  });
93
78
 
94
79
  it('should render at the top when side prop is "top"', () => {
95
- (useAuth as jest.Mock).mockReturnValue({
80
+ (useAuth as Mock).mockReturnValue({
96
81
  impersonator: { email: 'admin@example.com' },
97
82
  user: { id: '123', email: 'user@example.com' },
98
83
  organizationId: null,
99
- loading: false,
100
84
  });
101
85
 
102
86
  const { container } = render(<Impersonation side="top" />);
@@ -105,30 +89,163 @@ describe('Impersonation', () => {
105
89
  });
106
90
 
107
91
  it('should merge custom styles with default styles', () => {
108
- (useAuth as jest.Mock).mockReturnValue({
92
+ (useAuth as Mock).mockReturnValue({
109
93
  impersonator: { email: 'admin@example.com' },
110
94
  user: { id: '123', email: 'user@example.com' },
111
95
  organizationId: null,
112
- loading: false,
113
96
  });
114
97
 
115
98
  const customStyle = { backgroundColor: 'red' };
116
99
  const { container } = render(<Impersonation style={customStyle} />);
117
100
  const root = container.querySelector('[data-workos-impersonation-root]');
118
- expect(root).toHaveStyle({ backgroundColor: 'red' });
101
+ expect((root as HTMLElement).style.backgroundColor).toBe('red');
119
102
  });
120
103
 
121
104
  it('should should sign out when the Stop button is called', async () => {
122
- (useAuth as jest.Mock).mockReturnValue({
105
+ (useAuth as Mock).mockReturnValue({
123
106
  impersonator: { email: 'admin@example.com' },
124
107
  user: { id: '123', email: 'user@example.com' },
125
108
  organizationId: null,
126
- loading: false,
127
109
  });
128
110
 
129
111
  render(<Impersonation />);
130
112
  const stopButton = await screen.findByText('Stop');
131
113
  stopButton.click();
132
- expect(handleSignOutAction).toHaveBeenCalled();
114
+ expect(handleSignOutAction).toHaveBeenCalledWith({});
115
+ });
116
+
117
+ it('should pass returnTo prop to handleSignOutAction when provided', async () => {
118
+ (useAuth as Mock).mockReturnValue({
119
+ impersonator: { email: 'admin@example.com' },
120
+ user: { id: '123', email: 'user@example.com' },
121
+ organizationId: null,
122
+ loading: false,
123
+ });
124
+
125
+ const returnTo = '/dashboard';
126
+ render(<Impersonation returnTo={returnTo} />);
127
+ const stopButton = await screen.findByText('Stop');
128
+ stopButton.click();
129
+ expect(handleSignOutAction).toHaveBeenCalledWith({ returnTo });
130
+ });
131
+
132
+ it('should not call getOrganizationAction when organizationId is not provided', () => {
133
+ (useAuth as Mock).mockReturnValue({
134
+ impersonator: { email: 'admin@example.com' },
135
+ user: { id: '123', email: 'user@example.com' },
136
+ organizationId: null,
137
+ });
138
+
139
+ render(<Impersonation />);
140
+ expect(getOrganizationAction).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it('should not call getOrganizationAction when impersonator is not present', () => {
144
+ (useAuth as Mock).mockReturnValue({
145
+ impersonator: null,
146
+ user: { id: '123', email: 'user@example.com' },
147
+ organizationId: 'org_123',
148
+ });
149
+
150
+ render(<Impersonation />);
151
+ expect(getOrganizationAction).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('should not call getOrganizationAction when user is not present', () => {
155
+ (useAuth as Mock).mockReturnValue({
156
+ impersonator: { email: 'admin@example.com' },
157
+ user: null,
158
+ organizationId: 'org_123',
159
+ });
160
+
161
+ render(<Impersonation />);
162
+ expect(getOrganizationAction).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it('should not call getOrganizationAction again when organization is already loaded with same ID', async () => {
166
+ const mockOrg = {
167
+ id: 'org_123',
168
+ name: 'Test Org',
169
+ };
170
+
171
+ (getOrganizationAction as Mock).mockResolvedValue(mockOrg);
172
+
173
+ const { rerender } = await act(async () => {
174
+ (useAuth as Mock).mockReturnValue({
175
+ impersonator: { email: 'admin@example.com' },
176
+ user: { id: '123', email: 'user@example.com' },
177
+ organizationId: 'org_123',
178
+ });
179
+
180
+ return render(<Impersonation />);
181
+ });
182
+
183
+ // Wait for the initial call to complete
184
+ await act(async () => {
185
+ await new Promise((resolve) => setTimeout(resolve, 0));
186
+ });
187
+
188
+ expect(getOrganizationAction).toHaveBeenCalledTimes(1);
189
+
190
+ // Rerender with the same organizationId
191
+ await act(async () => {
192
+ (useAuth as Mock).mockReturnValue({
193
+ impersonator: { email: 'admin@example.com' },
194
+ user: { id: '123', email: 'user@example.com' },
195
+ organizationId: 'org_123',
196
+ });
197
+
198
+ rerender(<Impersonation />);
199
+ });
200
+
201
+ // Should still be called only once
202
+ expect(getOrganizationAction).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it('should call getOrganizationAction again when organizationId changes', async () => {
206
+ const mockOrg1 = {
207
+ id: 'org_123',
208
+ name: 'Test Org 1',
209
+ };
210
+
211
+ const mockOrg2 = {
212
+ id: 'org_456',
213
+ name: 'Test Org 2',
214
+ };
215
+
216
+ (getOrganizationAction as Mock).mockResolvedValueOnce(mockOrg1).mockResolvedValueOnce(mockOrg2);
217
+
218
+ const { rerender } = await act(async () => {
219
+ (useAuth as Mock).mockReturnValue({
220
+ impersonator: { email: 'admin@example.com' },
221
+ user: { id: '123', email: 'user@example.com' },
222
+ organizationId: 'org_123',
223
+ });
224
+
225
+ return render(<Impersonation />);
226
+ });
227
+
228
+ // Wait for the initial call to complete
229
+ await act(async () => {
230
+ await new Promise((resolve) => setTimeout(resolve, 0));
231
+ });
232
+
233
+ expect(getOrganizationAction).toHaveBeenCalledTimes(1);
234
+ expect(getOrganizationAction).toHaveBeenCalledWith('org_123');
235
+
236
+ // Rerender with a different organizationId
237
+ await act(async () => {
238
+ (useAuth as Mock).mockReturnValue({
239
+ impersonator: { email: 'admin@example.com' },
240
+ user: { id: '123', email: 'user@example.com' },
241
+ organizationId: 'org_456',
242
+ });
243
+
244
+ rerender(<Impersonation />);
245
+ });
246
+
247
+ // Should be called again with the new ID
248
+ expect(getOrganizationAction).toHaveBeenCalledTimes(2);
249
+ expect(getOrganizationAction).toHaveBeenCalledWith('org_456');
133
250
  });
134
251
  });
@@ -9,53 +9,60 @@ 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
28
30
  {...props}
29
31
  data-workos-impersonation-root=""
30
- style={{
31
- 'position': 'fixed',
32
- 'inset': 0,
33
- 'pointerEvents': 'none',
34
- 'zIndex': 9999,
35
-
36
- // short properties with defaults for authoring convenience
37
- '--wi-minimized': '0',
38
- '--wi-s': 'min(max(var(--workos-impersonation-size, 4px), 2px), 15px)',
39
- '--wi-bgc': 'var(--workos-impersonation-background-color, #fce654)',
40
- '--wi-c': 'var(--workos-impersonation-color, #1a1600)',
41
- '--wi-bc': 'var(--workos-impersonation-border-color, #e0c36c)',
42
- '--wi-bw': 'var(--workos-impersonation-border-width, 1px)',
43
-
44
- ...props.style,
45
- }}
32
+ style={
33
+ {
34
+ position: 'fixed',
35
+ inset: 0,
36
+ pointerEvents: 'none',
37
+ zIndex: 9999,
38
+
39
+ // short properties with defaults for authoring convenience
40
+ '--wi-minimized': '0',
41
+ '--wi-s': 'min(max(var(--workos-impersonation-size, 4px), 2px), 15px)',
42
+ '--wi-bgc': 'var(--workos-impersonation-background-color, #fce654)',
43
+ '--wi-c': 'var(--workos-impersonation-color, #1a1600)',
44
+ '--wi-bc': 'var(--workos-impersonation-border-color, #e0c36c)',
45
+ '--wi-bw': 'var(--workos-impersonation-border-width, 1px)',
46
+
47
+ ...props.style,
48
+ } as React.CSSProperties
49
+ }
46
50
  >
47
51
  <div
48
- style={{
49
- '--wi-frame-size': 'calc(var(--wi-s) * (1 - var(--wi-minimized)) + var(--wi-minimized) * var(--wi-bw) * -1)',
50
- 'position': 'absolute',
51
- 'inset': 'calc(var(--wi-frame-size) * -1)',
52
- 'borderRadius': 'calc(var(--wi-frame-size) * 3)',
53
- 'boxShadow': `
52
+ style={
53
+ {
54
+ '--wi-frame-size':
55
+ 'calc(var(--wi-s) * (1 - var(--wi-minimized)) + var(--wi-minimized) * var(--wi-bw) * -1)',
56
+ position: 'absolute',
57
+ inset: 'calc(var(--wi-frame-size) * -1)',
58
+ borderRadius: 'calc(var(--wi-frame-size) * 3)',
59
+ boxShadow: `
54
60
  inset 0 0 0 calc(var(--wi-frame-size) * 2) var(--wi-bgc),
55
61
  inset 0 0 0 calc(var(--wi-frame-size) * 2 + var(--wi-bw)) var(--wi-bc)
56
62
  `,
57
- 'transition': 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
58
- }}
63
+ transition: 'all 500ms cubic-bezier(0.16, 1, 0.3, 1)',
64
+ } as React.CSSProperties
65
+ }
59
66
  />
60
67
 
61
68
  <div
@@ -78,7 +85,7 @@ export function Impersonation({ side = 'bottom', ...props }: ImpersonationProps)
78
85
  <form
79
86
  onSubmit={async (event) => {
80
87
  event.preventDefault();
81
- await handleSignOutAction();
88
+ await handleSignOutAction({ returnTo });
82
89
  }}
83
90
  style={{
84
91
  display: 'flex',
@@ -14,6 +14,7 @@ describe('MinMaxButton', () => {
14
14
  afterEach(() => {
15
15
  // Clean up after each test
16
16
  document.body.innerHTML = '';
17
+ vi.restoreAllMocks();
17
18
  });
18
19
 
19
20
  it('sets minimized value when clicked', () => {
@@ -33,7 +34,7 @@ describe('MinMaxButton', () => {
33
34
  const root = document.querySelector('[data-workos-impersonation-root]');
34
35
 
35
36
  // Mock querySelector to return null for this test
36
- jest.spyOn(document, 'querySelector').mockReturnValue(null);
37
+ vi.spyOn(document, 'querySelector').mockReturnValue(null);
37
38
 
38
39
  act(() => {
39
40
  getByRole('button').click();
@@ -1,20 +1,21 @@
1
+ import type { Mock } from 'vitest';
1
2
  import { tokenStore, TokenStore } from './tokenStore.js';
2
3
  import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
3
4
 
4
- jest.mock('../actions.js', () => ({
5
- getAccessTokenAction: jest.fn(),
6
- refreshAccessTokenAction: jest.fn(),
5
+ vi.mock('../actions.js', () => ({
6
+ getAccessTokenAction: vi.fn(),
7
+ refreshAccessTokenAction: vi.fn(),
7
8
  }));
8
9
 
9
- const mockGetAccessTokenAction = getAccessTokenAction as jest.Mock;
10
- const mockRefreshAccessTokenAction = refreshAccessTokenAction as jest.Mock;
10
+ const mockGetAccessTokenAction = getAccessTokenAction as Mock;
11
+ const mockRefreshAccessTokenAction = refreshAccessTokenAction as Mock;
11
12
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
13
  const _global = global as any;
13
14
 
14
15
  describe('tokenStore', () => {
15
16
  beforeEach(() => {
16
- jest.useFakeTimers();
17
- jest.resetAllMocks();
17
+ vi.useFakeTimers();
18
+ vi.resetAllMocks();
18
19
  tokenStore.reset();
19
20
 
20
21
  // Clean up DOM globals
@@ -23,10 +24,10 @@ describe('tokenStore', () => {
23
24
  });
24
25
 
25
26
  afterEach(() => {
26
- jest.clearAllTimers();
27
- jest.useRealTimers();
27
+ vi.clearAllTimers();
28
+ vi.useRealTimers();
28
29
  tokenStore.reset();
29
- jest.restoreAllMocks();
30
+ vi.restoreAllMocks();
30
31
 
31
32
  // Clean up DOM globals
32
33
  delete _global.document;
@@ -54,7 +55,7 @@ describe('tokenStore', () => {
54
55
  resolvePromise = resolve;
55
56
  });
56
57
 
57
- mockRefreshAccessTokenAction.mockReturnValue(slowPromise);
58
+ mockRefreshAccessTokenAction.mockReturnValue(slowPromise.then((t) => ({ accessToken: t })));
58
59
 
59
60
  expect(tokenStore.isRefreshing()).toBe(false);
60
61
 
@@ -124,18 +125,25 @@ describe('tokenStore', () => {
124
125
  const refreshedToken =
125
126
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
126
127
 
127
- // Set expiring token first
128
+ // Set expiring token first — also set refresh mock since getAccessTokenSilently
129
+ // will trigger refresh for expiring tokens
128
130
  mockGetAccessTokenAction.mockResolvedValue(expiringToken);
131
+ mockRefreshAccessTokenAction.mockResolvedValueOnce({ accessToken: expiringToken });
129
132
  await tokenStore.getAccessTokenSilently();
130
133
 
131
- // Setup refresh mock
132
- mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
134
+ // Clear mocks to track subsequent calls
135
+ mockGetAccessTokenAction.mockClear();
136
+ mockRefreshAccessTokenAction.mockClear();
133
137
 
134
- // Now call getAccessToken - should trigger refresh
138
+ // Setup refresh to return new token
139
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
140
+
141
+ // Now call getAccessToken - should trigger refresh due to expiring token
135
142
  const token = await tokenStore.getAccessToken();
136
143
 
137
- expect(token).toBe(refreshedToken);
144
+ // Should have called refresh since existing token was expiring
138
145
  expect(mockRefreshAccessTokenAction).toHaveBeenCalled();
146
+ expect(token).toBe(refreshedToken);
139
147
  });
140
148
 
141
149
  it('should refresh when no token exists', async () => {
@@ -192,7 +200,7 @@ describe('tokenStore', () => {
192
200
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
193
201
 
194
202
  mockGetAccessTokenAction.mockResolvedValue(expiredToken);
195
- mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
203
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
196
204
 
197
205
  const token = await tokenStore.getAccessTokenSilently();
198
206
 
@@ -213,7 +221,7 @@ describe('tokenStore', () => {
213
221
  };
214
222
  const validToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(validPayload))}.mock-signature`;
215
223
 
216
- const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
224
+ const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
217
225
  mockGetAccessTokenAction.mockResolvedValue(validToken);
218
226
 
219
227
  await tokenStore.getAccessTokenSilently();
@@ -227,7 +235,7 @@ describe('tokenStore', () => {
227
235
 
228
236
  describe('subscriber management', () => {
229
237
  it('should notify subscribers when state changes', () => {
230
- const listener = jest.fn();
238
+ const listener = vi.fn();
231
239
  const unsubscribe = tokenStore.subscribe(listener);
232
240
 
233
241
  // Trigger a state change
@@ -256,14 +264,14 @@ describe('tokenStore', () => {
256
264
  mockGetAccessTokenAction.mockResolvedValue(validToken);
257
265
 
258
266
  // Subscribe to create a listener
259
- const listener = jest.fn();
267
+ const listener = vi.fn();
260
268
  const unsubscribe = tokenStore.subscribe(listener);
261
269
 
262
270
  // Get token to schedule a refresh
263
271
  await tokenStore.getAccessTokenSilently();
264
272
 
265
273
  // Spy on clearTimeout
266
- const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
274
+ const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
267
275
 
268
276
  // Unsubscribe the last (only) subscriber - should clear timeout
269
277
  unsubscribe();
@@ -287,15 +295,18 @@ describe('tokenStore', () => {
287
295
  const refreshedToken =
288
296
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
289
297
 
290
- // First set an expiring token
298
+ // First set an expiring token — also set refresh mock since getAccessTokenSilently
299
+ // will trigger refresh for expiring tokens
291
300
  mockGetAccessTokenAction.mockResolvedValue(expiringToken);
301
+ mockRefreshAccessTokenAction.mockResolvedValueOnce({ accessToken: expiringToken });
292
302
  await tokenStore.getAccessTokenSilently();
293
303
 
294
304
  // Clear mocks
295
305
  mockGetAccessTokenAction.mockClear();
306
+ mockRefreshAccessTokenAction.mockClear();
296
307
 
297
308
  // Setup refresh to return new token
298
- mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
309
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: refreshedToken });
299
310
 
300
311
  // Call getAccessToken again - should trigger refresh due to expiring token
301
312
  const token = await tokenStore.getAccessToken();
@@ -354,7 +365,7 @@ describe('tokenStore', () => {
354
365
 
355
366
  it('should consume eager auth cookie on first getAccessToken call', async () => {
356
367
  const eagerToken = 'eager-auth-token';
357
- const mockCookieSetter = jest.fn();
368
+ const mockCookieSetter = vi.fn();
358
369
 
359
370
  // Mock document.cookie with both getter and setter
360
371
  let cookieValue = `workos-access-token=${eagerToken};`;
@@ -404,7 +415,7 @@ describe('tokenStore', () => {
404
415
  iat: now - 40,
405
416
  };
406
417
  const fastToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(fastPayload))}.mock-signature`;
407
- const mockCookieSetter = jest.fn();
418
+ const mockCookieSetter = vi.fn();
408
419
 
409
420
  let cookieValue = `workos-access-token=${fastToken};`;
410
421
 
@@ -433,7 +444,7 @@ describe('tokenStore', () => {
433
444
  configurable: true,
434
445
  });
435
446
 
436
- const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
447
+ const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
437
448
 
438
449
  // Call getAccessTokenSilently to trigger fast cookie consumption and refresh scheduling
439
450
  const token = await tokenStore.getAccessTokenSilently();
@@ -480,7 +491,7 @@ describe('tokenStore', () => {
480
491
 
481
492
  it('should handle HTTP protocol for cookie deletion', async () => {
482
493
  const eagerToken = 'http-token';
483
- const mockCookieSetter = jest.fn();
494
+ const mockCookieSetter = vi.fn();
484
495
 
485
496
  let cookieValue = `workos-access-token=${eagerToken};`;
486
497
 
@@ -526,11 +537,14 @@ describe('tokenStore', () => {
526
537
  await tokenStore.getAccessTokenSilently();
527
538
 
528
539
  // Now simulate network error during refresh
529
- mockRefreshAccessTokenAction.mockRejectedValue(new Error('Network error'));
540
+ mockRefreshAccessTokenAction.mockResolvedValue({
541
+ accessToken: undefined,
542
+ error: 'Failed to refresh access token',
543
+ });
530
544
 
531
545
  try {
532
546
  await tokenStore.refreshToken();
533
- } catch (e) {
547
+ } catch {
534
548
  // Expected to throw
535
549
  }
536
550
 
@@ -543,11 +557,11 @@ describe('tokenStore', () => {
543
557
  it('should convert non-Error objects to Error instances', async () => {
544
558
  const errorString = 'network timeout';
545
559
 
546
- mockRefreshAccessTokenAction.mockRejectedValue(errorString);
560
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: undefined, error: errorString });
547
561
 
548
562
  try {
549
563
  await tokenStore.refreshToken();
550
- } catch (e) {
564
+ } catch {
551
565
  // Expected to throw
552
566
  }
553
567
 
@@ -624,7 +638,7 @@ describe('tokenStore', () => {
624
638
  await tokenStore.getAccessTokenSilently();
625
639
 
626
640
  // Spy on clearTimeout
627
- const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
641
+ const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
628
642
 
629
643
  // Clear token should clear the refresh timeout
630
644
  tokenStore.clearToken();
@@ -649,7 +663,7 @@ describe('tokenStore', () => {
649
663
 
650
664
  mockRefreshAccessTokenAction.mockImplementation(() => {
651
665
  callCount++;
652
- return slowPromise;
666
+ return slowPromise.then((t) => ({ accessToken: t }));
653
667
  });
654
668
 
655
669
  // Clear any existing refresh promise
@@ -691,21 +705,22 @@ describe('tokenStore', () => {
691
705
  });
692
706
 
693
707
  describe('refresh state management', () => {
694
- it('should preserve Error instances without conversion', async () => {
695
- const errorInstance = new Error('actual error instance');
708
+ it('should create Error from server action error string', async () => {
709
+ const errorMessage = 'actual error instance';
696
710
 
697
- // Mock refresh to throw an Error instance
698
- mockRefreshAccessTokenAction.mockRejectedValue(errorInstance);
711
+ // Mock refresh to return an error result (as server actions do)
712
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: undefined, error: errorMessage });
699
713
 
700
714
  try {
701
715
  await tokenStore.refreshToken();
702
- } catch (e) {
716
+ } catch {
703
717
  // Expected to throw
704
718
  }
705
719
 
706
- // Verify the Error instance was preserved without conversion
720
+ // Verify an Error was created from the error string
707
721
  const state = tokenStore.getSnapshot();
708
- expect(state.error).toBe(errorInstance); // Same instance, not a new one
722
+ expect(state.error).toBeInstanceOf(Error);
723
+ expect(state.error?.message).toBe(errorMessage);
709
724
  });
710
725
 
711
726
  it('should update state for manual refresh', async () => {
@@ -717,7 +732,7 @@ describe('tokenStore', () => {
717
732
  await tokenStore.getAccessTokenSilently();
718
733
 
719
734
  // Mock refresh to return new token
720
- mockRefreshAccessTokenAction.mockResolvedValue(newToken);
735
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: newToken });
721
736
 
722
737
  // Call manual refresh which should update state
723
738
  const result = await tokenStore.refreshToken();
@@ -736,9 +751,9 @@ describe('tokenStore', () => {
736
751
 
737
752
  // Clear mocks and set up spy on setState
738
753
  mockGetAccessTokenAction.mockClear();
739
- mockRefreshAccessTokenAction.mockResolvedValue(existingToken); // Same token
754
+ mockRefreshAccessTokenAction.mockResolvedValue({ accessToken: existingToken }); // Same token
740
755
 
741
- const listener = jest.fn();
756
+ const listener = vi.fn();
742
757
  tokenStore.subscribe(listener);
743
758
 
744
759
  // Force a silent refresh that returns the same token
@@ -752,7 +767,7 @@ describe('tokenStore', () => {
752
767
 
753
768
  describe('TokenStore constructor', () => {
754
769
  const setupMockEnv = (cookieValue = '', protocol = 'https:') => {
755
- const mockCookieSetter = jest.fn();
770
+ const mockCookieSetter = vi.fn();
756
771
 
757
772
  Object.defineProperty(_global, 'document', {
758
773
  value: { cookie: cookieValue },