@workos-inc/authkit-nextjs 2.6.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 (46) hide show
  1. package/README.md +124 -29
  2. package/dist/esm/components/tokenStore.js +110 -11
  3. package/dist/esm/components/tokenStore.js.map +1 -1
  4. package/dist/esm/components/useAccessToken.js +6 -1
  5. package/dist/esm/components/useAccessToken.js.map +1 -1
  6. package/dist/esm/cookie.js +51 -0
  7. package/dist/esm/cookie.js.map +1 -1
  8. package/dist/esm/middleware.js +2 -2
  9. package/dist/esm/middleware.js.map +1 -1
  10. package/dist/esm/session.js +35 -2
  11. package/dist/esm/session.js.map +1 -1
  12. package/dist/esm/test-helpers.js +57 -0
  13. package/dist/esm/test-helpers.js.map +1 -0
  14. package/dist/esm/types/components/tokenStore.d.ts +7 -2
  15. package/dist/esm/types/cookie.d.ts +1 -0
  16. package/dist/esm/types/interfaces.d.ts +2 -0
  17. package/dist/esm/types/middleware.d.ts +1 -1
  18. package/dist/esm/types/session.d.ts +1 -1
  19. package/dist/esm/types/test-helpers.d.ts +3 -0
  20. package/dist/esm/types/workos.d.ts +1 -1
  21. package/dist/esm/workos.js +1 -1
  22. package/package.json +4 -3
  23. package/src/actions.spec.ts +100 -0
  24. package/src/auth.spec.ts +347 -0
  25. package/src/authkit-callback-route.spec.ts +258 -0
  26. package/src/components/authkit-provider.spec.tsx +471 -0
  27. package/src/components/button.spec.tsx +46 -0
  28. package/src/components/impersonation.spec.tsx +134 -0
  29. package/src/components/min-max-button.spec.tsx +60 -0
  30. package/src/components/tokenStore.spec.ts +816 -0
  31. package/src/components/tokenStore.ts +147 -12
  32. package/src/components/useAccessToken.spec.tsx +731 -0
  33. package/src/components/useAccessToken.ts +6 -1
  34. package/src/components/useTokenClaims.spec.tsx +194 -0
  35. package/src/cookie.spec.ts +276 -0
  36. package/src/cookie.ts +56 -0
  37. package/src/get-authorization-url.spec.ts +60 -0
  38. package/src/interfaces.ts +2 -0
  39. package/src/jwt.spec.ts +159 -0
  40. package/src/middleware.ts +2 -1
  41. package/src/session.spec.ts +1152 -0
  42. package/src/session.ts +41 -1
  43. package/src/test-helpers.ts +70 -0
  44. package/src/utils.spec.ts +142 -0
  45. package/src/workos.spec.ts +67 -0
  46. package/src/workos.ts +1 -1
@@ -0,0 +1,816 @@
1
+ import { tokenStore, TokenStore } from './tokenStore.js';
2
+ import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
3
+
4
+ jest.mock('../actions.js', () => ({
5
+ getAccessTokenAction: jest.fn(),
6
+ refreshAccessTokenAction: jest.fn(),
7
+ }));
8
+
9
+ const mockGetAccessTokenAction = getAccessTokenAction as jest.Mock;
10
+ const mockRefreshAccessTokenAction = refreshAccessTokenAction as jest.Mock;
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const _global = global as any;
13
+
14
+ describe('tokenStore', () => {
15
+ beforeEach(() => {
16
+ jest.useFakeTimers();
17
+ jest.resetAllMocks();
18
+ tokenStore.reset();
19
+
20
+ // Clean up DOM globals
21
+ delete _global.document;
22
+ delete _global.window;
23
+ });
24
+
25
+ afterEach(() => {
26
+ jest.clearAllTimers();
27
+ jest.useRealTimers();
28
+ tokenStore.reset();
29
+ jest.restoreAllMocks();
30
+
31
+ // Clean up DOM globals
32
+ delete _global.document;
33
+ delete _global.window;
34
+ });
35
+
36
+ describe('getServerSnapshot', () => {
37
+ it('should return a static server snapshot', () => {
38
+ const snapshot = tokenStore.getServerSnapshot();
39
+ expect(snapshot).toEqual({
40
+ token: undefined,
41
+ loading: false,
42
+ error: null,
43
+ });
44
+ });
45
+ });
46
+
47
+ describe('isRefreshing', () => {
48
+ it('should return true when a refresh is in progress', async () => {
49
+ const mockToken =
50
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
51
+
52
+ let resolvePromise: (value: string) => void;
53
+ const slowPromise = new Promise<string>((resolve) => {
54
+ resolvePromise = resolve;
55
+ });
56
+
57
+ mockRefreshAccessTokenAction.mockReturnValue(slowPromise);
58
+
59
+ expect(tokenStore.isRefreshing()).toBe(false);
60
+
61
+ // Start a refresh
62
+ const refreshPromise = tokenStore.refreshToken();
63
+
64
+ expect(tokenStore.isRefreshing()).toBe(true);
65
+
66
+ // Complete the refresh
67
+ resolvePromise!(mockToken);
68
+ await refreshPromise;
69
+
70
+ expect(tokenStore.isRefreshing()).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('getAccessToken', () => {
75
+ it('should return existing valid JWT token without refreshing', async () => {
76
+ const mockToken =
77
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
78
+
79
+ // Set token in store first
80
+ mockGetAccessTokenAction.mockResolvedValue(mockToken);
81
+ await tokenStore.getAccessTokenSilently();
82
+
83
+ // Clear mocks
84
+ mockGetAccessTokenAction.mockClear();
85
+ mockRefreshAccessTokenAction.mockClear();
86
+
87
+ // Now call getAccessToken - should return cached token
88
+ const token = await tokenStore.getAccessToken();
89
+
90
+ expect(token).toBe(mockToken);
91
+ expect(mockGetAccessTokenAction).not.toHaveBeenCalled();
92
+ expect(mockRefreshAccessTokenAction).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it('should return existing opaque token without refreshing', async () => {
96
+ const opaqueToken = 'opaque-token-string';
97
+
98
+ // Set opaque token in store first
99
+ mockGetAccessTokenAction.mockResolvedValue(opaqueToken);
100
+ await tokenStore.getAccessTokenSilently();
101
+
102
+ // Clear mocks
103
+ mockGetAccessTokenAction.mockClear();
104
+ mockRefreshAccessTokenAction.mockClear();
105
+
106
+ // Now call getAccessToken - should return cached opaque token
107
+ const token = await tokenStore.getAccessToken();
108
+
109
+ expect(token).toBe(opaqueToken);
110
+ expect(mockGetAccessTokenAction).not.toHaveBeenCalled();
111
+ expect(mockRefreshAccessTokenAction).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('should refresh when JWT is expiring', async () => {
115
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
116
+ const expiringPayload = {
117
+ sub: '1234567890',
118
+ sid: 'session_123',
119
+ exp: currentTimeInSeconds + 25, // Within 60-second buffer
120
+ iat: currentTimeInSeconds - 35,
121
+ };
122
+ const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(expiringPayload))}.mock-signature`;
123
+
124
+ const refreshedToken =
125
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
126
+
127
+ // Set expiring token first
128
+ mockGetAccessTokenAction.mockResolvedValue(expiringToken);
129
+ await tokenStore.getAccessTokenSilently();
130
+
131
+ // Setup refresh mock
132
+ mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
133
+
134
+ // Now call getAccessToken - should trigger refresh
135
+ const token = await tokenStore.getAccessToken();
136
+
137
+ expect(token).toBe(refreshedToken);
138
+ expect(mockRefreshAccessTokenAction).toHaveBeenCalled();
139
+ });
140
+
141
+ it('should refresh when no token exists', async () => {
142
+ const mockToken =
143
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
144
+
145
+ mockGetAccessTokenAction.mockResolvedValue(mockToken);
146
+
147
+ const token = await tokenStore.getAccessToken();
148
+
149
+ expect(token).toBe(mockToken);
150
+ expect(mockGetAccessTokenAction).toHaveBeenCalled();
151
+ });
152
+ });
153
+
154
+ describe('parseToken behavior', () => {
155
+ it('should handle token with no exp field', () => {
156
+ const tokenWithoutExp = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ sub: '123' }))}.mock-signature`;
157
+
158
+ const result = tokenStore.parseToken(tokenWithoutExp);
159
+
160
+ // Token without exp field should return null (treated as opaque)
161
+ expect(result).toBeNull();
162
+ });
163
+
164
+ it('should identify expired tokens', () => {
165
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
166
+ const expiredPayload = {
167
+ sub: '1234567890',
168
+ sid: 'session_123',
169
+ exp: currentTimeInSeconds - 10, // Already expired
170
+ iat: currentTimeInSeconds - 70,
171
+ };
172
+ const expiredToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(expiredPayload))}.mock-signature`;
173
+
174
+ const result = tokenStore.parseToken(expiredToken);
175
+
176
+ expect(result).not.toBeNull();
177
+ expect(result?.isExpiring).toBe(true);
178
+ expect(result?.expiresAt).toBe(expiredPayload.exp);
179
+ });
180
+
181
+ it('should trigger refresh for expired tokens during silent fetch', async () => {
182
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
183
+ const expiredPayload = {
184
+ sub: '1234567890',
185
+ sid: 'session_123',
186
+ exp: currentTimeInSeconds - 10, // Already expired
187
+ iat: currentTimeInSeconds - 70,
188
+ };
189
+ const expiredToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(expiredPayload))}.mock-signature`;
190
+
191
+ const refreshedToken =
192
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
193
+
194
+ mockGetAccessTokenAction.mockResolvedValue(expiredToken);
195
+ mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
196
+
197
+ const token = await tokenStore.getAccessTokenSilently();
198
+
199
+ // Should have triggered refresh due to expired token
200
+ expect(mockRefreshAccessTokenAction).toHaveBeenCalled();
201
+ expect(token).toBe(refreshedToken);
202
+ });
203
+ });
204
+
205
+ describe('refresh scheduling', () => {
206
+ it('should schedule background refresh for valid tokens', async () => {
207
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
208
+ const validPayload = {
209
+ sub: '1234567890',
210
+ sid: 'session_123',
211
+ exp: currentTimeInSeconds + 3600, // 1 hour from now
212
+ iat: currentTimeInSeconds - 40,
213
+ };
214
+ const validToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(validPayload))}.mock-signature`;
215
+
216
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
217
+ mockGetAccessTokenAction.mockResolvedValue(validToken);
218
+
219
+ await tokenStore.getAccessTokenSilently();
220
+
221
+ // Should have scheduled background refresh
222
+ expect(setTimeoutSpy).toHaveBeenCalled();
223
+
224
+ setTimeoutSpy.mockRestore();
225
+ });
226
+ });
227
+
228
+ describe('subscriber management', () => {
229
+ it('should notify subscribers when state changes', () => {
230
+ const listener = jest.fn();
231
+ const unsubscribe = tokenStore.subscribe(listener);
232
+
233
+ // Trigger a state change
234
+ tokenStore.clearToken();
235
+
236
+ expect(listener).toHaveBeenCalled();
237
+
238
+ // Test unsubscribe prevents future notifications
239
+ unsubscribe();
240
+ listener.mockClear();
241
+
242
+ tokenStore.clearToken();
243
+ expect(listener).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it('should clear refresh timeout when last subscriber unsubscribes', async () => {
247
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
248
+ const validPayload = {
249
+ sub: '1234567890',
250
+ sid: 'session_123',
251
+ exp: currentTimeInSeconds + 3600, // 1 hour from now
252
+ iat: currentTimeInSeconds - 40,
253
+ };
254
+ const validToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(validPayload))}.mock-signature`;
255
+
256
+ mockGetAccessTokenAction.mockResolvedValue(validToken);
257
+
258
+ // Subscribe to create a listener
259
+ const listener = jest.fn();
260
+ const unsubscribe = tokenStore.subscribe(listener);
261
+
262
+ // Get token to schedule a refresh
263
+ await tokenStore.getAccessTokenSilently();
264
+
265
+ // Spy on clearTimeout
266
+ const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
267
+
268
+ // Unsubscribe the last (only) subscriber - should clear timeout
269
+ unsubscribe();
270
+
271
+ expect(clearTimeoutSpy).toHaveBeenCalled();
272
+ clearTimeoutSpy.mockRestore();
273
+ });
274
+ });
275
+
276
+ describe('token refresh behavior', () => {
277
+ it('should refresh expiring token during subsequent access', async () => {
278
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
279
+ const expiringPayload = {
280
+ sub: '1234567890',
281
+ sid: 'session_123',
282
+ exp: currentTimeInSeconds + 25, // Within 60-second buffer
283
+ iat: currentTimeInSeconds - 35,
284
+ };
285
+ const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(expiringPayload))}.mock-signature`;
286
+
287
+ const refreshedToken =
288
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
289
+
290
+ // First set an expiring token
291
+ mockGetAccessTokenAction.mockResolvedValue(expiringToken);
292
+ await tokenStore.getAccessTokenSilently();
293
+
294
+ // Clear mocks
295
+ mockGetAccessTokenAction.mockClear();
296
+
297
+ // Setup refresh to return new token
298
+ mockRefreshAccessTokenAction.mockResolvedValue(refreshedToken);
299
+
300
+ // Call getAccessToken again - should trigger refresh due to expiring token
301
+ const token = await tokenStore.getAccessToken();
302
+
303
+ // Should have called refresh since existing token was expiring
304
+ expect(mockRefreshAccessTokenAction).toHaveBeenCalled();
305
+ expect(token).toBe(refreshedToken);
306
+ });
307
+ });
308
+
309
+ describe('getAccessTokenSilently caching behavior', () => {
310
+ it('should return cached opaque token without making server call', async () => {
311
+ const opaqueToken = 'opaque-token-value';
312
+
313
+ // Set opaque token first
314
+ mockGetAccessTokenAction.mockResolvedValue(opaqueToken);
315
+ await tokenStore.getAccessTokenSilently();
316
+
317
+ // Clear mocks to verify no additional calls
318
+ mockGetAccessTokenAction.mockClear();
319
+ mockRefreshAccessTokenAction.mockClear();
320
+
321
+ // Call again - should return cached opaque token
322
+ const token = await tokenStore.getAccessTokenSilently();
323
+
324
+ expect(token).toBe(opaqueToken);
325
+ expect(mockGetAccessTokenAction).not.toHaveBeenCalled();
326
+ expect(mockRefreshAccessTokenAction).not.toHaveBeenCalled();
327
+ });
328
+
329
+ it('should return cached valid JWT token without making server call', async () => {
330
+ const validToken =
331
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
332
+
333
+ // Set valid token first
334
+ mockGetAccessTokenAction.mockResolvedValue(validToken);
335
+ await tokenStore.getAccessTokenSilently();
336
+
337
+ // Clear mocks to verify no additional calls
338
+ mockGetAccessTokenAction.mockClear();
339
+ mockRefreshAccessTokenAction.mockClear();
340
+
341
+ // Call again - should return cached valid token
342
+ const token = await tokenStore.getAccessTokenSilently();
343
+
344
+ expect(token).toBe(validToken);
345
+ expect(mockGetAccessTokenAction).not.toHaveBeenCalled();
346
+ expect(mockRefreshAccessTokenAction).not.toHaveBeenCalled();
347
+ });
348
+ });
349
+
350
+ describe('eager auth cookie handling', () => {
351
+ beforeEach(() => {
352
+ tokenStore.reset();
353
+ });
354
+
355
+ it('should consume eager auth cookie on first getAccessToken call', async () => {
356
+ const eagerToken = 'eager-auth-token';
357
+ const mockCookieSetter = jest.fn();
358
+
359
+ // Mock document.cookie with both getter and setter
360
+ let cookieValue = `workos-access-token=${eagerToken};`;
361
+
362
+ Object.defineProperty(global, 'document', {
363
+ value: global.document || {},
364
+ writable: true,
365
+ configurable: true,
366
+ });
367
+
368
+ Object.defineProperty(document, 'cookie', {
369
+ get: () => cookieValue,
370
+ set: (value: string) => {
371
+ mockCookieSetter(value);
372
+ cookieValue = value;
373
+ },
374
+ configurable: true,
375
+ });
376
+
377
+ Object.defineProperty(global, 'window', {
378
+ value: {
379
+ location: {
380
+ protocol: 'https:',
381
+ },
382
+ },
383
+ writable: true,
384
+ configurable: true,
385
+ });
386
+
387
+ const token = await tokenStore.getAccessToken();
388
+
389
+ expect(token).toBe(eagerToken);
390
+ // Verify cookie was deleted after consumption
391
+ expect(mockCookieSetter).toHaveBeenCalledWith('workos-access-token=; SameSite=Lax; Max-Age=0; Secure');
392
+
393
+ // Verify token is now in state
394
+ const state = tokenStore.getSnapshot();
395
+ expect(state.token).toBe(eagerToken);
396
+ });
397
+
398
+ it('should schedule refresh for fast cookie with expiry', async () => {
399
+ const now = Math.floor(Date.now() / 1000);
400
+ const fastPayload = {
401
+ sub: 'user_456',
402
+ sid: 'session_456',
403
+ exp: now + 7200, // 2 hours from now
404
+ iat: now - 40,
405
+ };
406
+ const fastToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(fastPayload))}.mock-signature`;
407
+ const mockCookieSetter = jest.fn();
408
+
409
+ let cookieValue = `workos-access-token=${fastToken};`;
410
+
411
+ Object.defineProperty(_global, 'document', {
412
+ value: _global.document || {},
413
+ writable: true,
414
+ configurable: true,
415
+ });
416
+
417
+ Object.defineProperty(document, 'cookie', {
418
+ get: () => cookieValue,
419
+ set: (value: string) => {
420
+ mockCookieSetter(value);
421
+ cookieValue = value;
422
+ },
423
+ configurable: true,
424
+ });
425
+
426
+ Object.defineProperty(_global, 'window', {
427
+ value: {
428
+ location: {
429
+ protocol: 'https:',
430
+ },
431
+ },
432
+ writable: true,
433
+ configurable: true,
434
+ });
435
+
436
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
437
+
438
+ // Call getAccessTokenSilently to trigger fast cookie consumption and refresh scheduling
439
+ const token = await tokenStore.getAccessTokenSilently();
440
+
441
+ expect(token).toBe(fastToken);
442
+ expect(setTimeoutSpy).toHaveBeenCalled();
443
+
444
+ const state = tokenStore.getSnapshot();
445
+ expect(state.token).toBe(fastToken);
446
+ expect(state.loading).toBe(false);
447
+ expect(state.error).toBeNull();
448
+
449
+ setTimeoutSpy.mockRestore();
450
+ });
451
+
452
+ it('should handle server-side environment without document', async () => {
453
+ // Ensure document is undefined to simulate server environment
454
+ delete _global.document;
455
+
456
+ mockGetAccessTokenAction.mockResolvedValue('server-token');
457
+
458
+ const token = await tokenStore.getAccessToken();
459
+
460
+ expect(token).toBe('server-token');
461
+ expect(mockGetAccessTokenAction).toHaveBeenCalled();
462
+ });
463
+
464
+ it('should handle environment without cookie when consuming fast cookie', async () => {
465
+ Object.defineProperty(_global, 'document', {
466
+ value: {
467
+ cookie: '', // Empty cookie string
468
+ },
469
+ writable: true,
470
+ configurable: true,
471
+ });
472
+
473
+ mockGetAccessTokenAction.mockResolvedValue('fallback-token');
474
+
475
+ const token = await tokenStore.getAccessToken();
476
+
477
+ expect(token).toBe('fallback-token');
478
+ expect(mockGetAccessTokenAction).toHaveBeenCalled();
479
+ });
480
+
481
+ it('should handle HTTP protocol for cookie deletion', async () => {
482
+ const eagerToken = 'http-token';
483
+ const mockCookieSetter = jest.fn();
484
+
485
+ let cookieValue = `workos-access-token=${eagerToken};`;
486
+
487
+ Object.defineProperty(_global, 'document', {
488
+ value: _global.document || {},
489
+ writable: true,
490
+ configurable: true,
491
+ });
492
+
493
+ Object.defineProperty(document, 'cookie', {
494
+ get: () => cookieValue,
495
+ set: (value: string) => {
496
+ mockCookieSetter(value);
497
+ cookieValue = value;
498
+ },
499
+ configurable: true,
500
+ });
501
+
502
+ Object.defineProperty(_global, 'window', {
503
+ value: {
504
+ location: {
505
+ protocol: 'http:', // HTTP instead of HTTPS
506
+ },
507
+ },
508
+ writable: true,
509
+ configurable: true,
510
+ });
511
+
512
+ const token = await tokenStore.getAccessToken();
513
+
514
+ expect(token).toBe(eagerToken);
515
+ // Verify cookie was deleted without Secure flag for HTTP
516
+ expect(mockCookieSetter).toHaveBeenCalledWith('workos-access-token=; SameSite=Lax; Max-Age=0');
517
+ });
518
+ });
519
+
520
+ describe('error recovery', () => {
521
+ it('should preserve existing token when refresh fails', async () => {
522
+ const existingToken = 'existing-valid-token';
523
+
524
+ // Set up existing token
525
+ mockGetAccessTokenAction.mockResolvedValue(existingToken);
526
+ await tokenStore.getAccessTokenSilently();
527
+
528
+ // Now simulate network error during refresh
529
+ mockRefreshAccessTokenAction.mockRejectedValue(new Error('Network error'));
530
+
531
+ try {
532
+ await tokenStore.refreshToken();
533
+ } catch (e) {
534
+ // Expected to throw
535
+ }
536
+
537
+ const state = tokenStore.getSnapshot();
538
+ expect(state.token).toBe(existingToken); // Token preserved for retry
539
+ expect(state.error).toBeTruthy();
540
+ expect(state.loading).toBe(false);
541
+ });
542
+
543
+ it('should convert non-Error objects to Error instances', async () => {
544
+ const errorString = 'network timeout';
545
+
546
+ mockRefreshAccessTokenAction.mockRejectedValue(errorString);
547
+
548
+ try {
549
+ await tokenStore.refreshToken();
550
+ } catch (e) {
551
+ // Expected to throw
552
+ }
553
+
554
+ const state = tokenStore.getSnapshot();
555
+ expect(state.error).toBeInstanceOf(Error);
556
+ expect(state.error?.message).toBe(errorString);
557
+ });
558
+ });
559
+
560
+ describe('short-lived token handling', () => {
561
+ it('should use appropriate buffer for short-lived tokens', () => {
562
+ const now = Math.floor(Date.now() / 1000);
563
+ const shortLivedPayload = {
564
+ sub: 'user_123',
565
+ sid: 'session_123',
566
+ iat: now,
567
+ exp: now + 60, // 60 seconds - typical WorkOS token
568
+ };
569
+
570
+ const tokenString = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(
571
+ JSON.stringify(shortLivedPayload),
572
+ )}.mock-signature`;
573
+
574
+ const result = tokenStore.parseToken(tokenString);
575
+
576
+ expect(result).toBeTruthy();
577
+ // With 30-second buffer for short tokens, 60-second token should not be expiring immediately
578
+ expect(result?.isExpiring).toBe(false);
579
+
580
+ // But should be expiring when only 25 seconds left
581
+ const nearExpiryPayload = {
582
+ ...shortLivedPayload,
583
+ exp: now + 25,
584
+ };
585
+ const nearExpiryToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(
586
+ JSON.stringify(nearExpiryPayload),
587
+ )}.mock-signature`;
588
+
589
+ const nearExpiryResult = tokenStore.parseToken(nearExpiryToken);
590
+ expect(nearExpiryResult?.isExpiring).toBe(true);
591
+ });
592
+ });
593
+
594
+ describe('clearToken', () => {
595
+ it('should clear token and reset state', () => {
596
+ // First set a token
597
+ mockGetAccessTokenAction.mockResolvedValue('test-token');
598
+
599
+ // Call clearToken
600
+ tokenStore.clearToken();
601
+
602
+ const state = tokenStore.getSnapshot();
603
+ expect(state.token).toBeUndefined();
604
+ expect(state.error).toBeNull();
605
+ expect(state.loading).toBe(false);
606
+ });
607
+
608
+ it('should clear refresh timeout when token is cleared', async () => {
609
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
610
+ const validPayload = {
611
+ sub: '1234567890',
612
+ sid: 'session_123',
613
+ exp: currentTimeInSeconds + 3600, // 1 hour from now
614
+ iat: currentTimeInSeconds - 40,
615
+ };
616
+ const validToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(validPayload))}.mock-signature`;
617
+
618
+ mockGetAccessTokenAction.mockResolvedValue(validToken);
619
+
620
+ // Subscribe to prevent timeout from being cleared automatically
621
+ const unsubscribe = tokenStore.subscribe(() => {});
622
+
623
+ // Get token to schedule a refresh
624
+ await tokenStore.getAccessTokenSilently();
625
+
626
+ // Spy on clearTimeout
627
+ const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
628
+
629
+ // Clear token should clear the refresh timeout
630
+ tokenStore.clearToken();
631
+
632
+ expect(clearTimeoutSpy).toHaveBeenCalled();
633
+
634
+ unsubscribe();
635
+ clearTimeoutSpy.mockRestore();
636
+ });
637
+ });
638
+
639
+ describe('concurrent refresh prevention', () => {
640
+ it('should reuse existing refresh promise to prevent concurrent requests', async () => {
641
+ const mockToken = 'refresh-token';
642
+ let callCount = 0;
643
+
644
+ // Mock the action to track calls and be slow
645
+ let resolvePromise: (value: string) => void;
646
+ const slowPromise = new Promise<string>((resolve) => {
647
+ resolvePromise = resolve;
648
+ });
649
+
650
+ mockRefreshAccessTokenAction.mockImplementation(() => {
651
+ callCount++;
652
+ return slowPromise;
653
+ });
654
+
655
+ // Clear any existing refresh promise
656
+ tokenStore.reset();
657
+
658
+ // Start first refresh
659
+ const promise1 = tokenStore.refreshToken();
660
+
661
+ // Start second refresh immediately while first is still pending
662
+ const promise2 = tokenStore.refreshToken();
663
+
664
+ // Verify both calls eventually get the same result
665
+ resolvePromise!(mockToken);
666
+
667
+ const [result1, result2] = await Promise.all([promise1, promise2]);
668
+
669
+ // Both should get the token
670
+ expect(result1).toBe(mockToken);
671
+ expect(result2).toBe(mockToken);
672
+
673
+ // The key test: only one actual refresh should have been called
674
+ expect(callCount).toBe(1);
675
+ });
676
+ });
677
+
678
+ describe('compatibility and state management', () => {
679
+ it('should reset to clean state', () => {
680
+ // Set some state first
681
+ tokenStore.clearToken();
682
+
683
+ // Reset completely
684
+ tokenStore.reset();
685
+
686
+ const state = tokenStore.getSnapshot();
687
+ expect(state.token).toBeUndefined();
688
+ expect(state.loading).toBe(false);
689
+ expect(state.error).toBeNull();
690
+ });
691
+ });
692
+
693
+ describe('refresh state management', () => {
694
+ it('should preserve Error instances without conversion', async () => {
695
+ const errorInstance = new Error('actual error instance');
696
+
697
+ // Mock refresh to throw an Error instance
698
+ mockRefreshAccessTokenAction.mockRejectedValue(errorInstance);
699
+
700
+ try {
701
+ await tokenStore.refreshToken();
702
+ } catch (e) {
703
+ // Expected to throw
704
+ }
705
+
706
+ // Verify the Error instance was preserved without conversion
707
+ const state = tokenStore.getSnapshot();
708
+ expect(state.error).toBe(errorInstance); // Same instance, not a new one
709
+ });
710
+
711
+ it('should update state for manual refresh', async () => {
712
+ const oldToken = 'old-token';
713
+ const newToken = 'new-token';
714
+
715
+ // Set up old token
716
+ mockGetAccessTokenAction.mockResolvedValue(oldToken);
717
+ await tokenStore.getAccessTokenSilently();
718
+
719
+ // Mock refresh to return new token
720
+ mockRefreshAccessTokenAction.mockResolvedValue(newToken);
721
+
722
+ // Call manual refresh which should update state
723
+ const result = await tokenStore.refreshToken();
724
+
725
+ expect(result).toBe(newToken);
726
+ const state = tokenStore.getSnapshot();
727
+ expect(state.token).toBe(newToken);
728
+ });
729
+
730
+ it('should skip state update for silent refresh when token unchanged', async () => {
731
+ const existingToken = 'unchanged-token';
732
+
733
+ // Set up existing token
734
+ mockGetAccessTokenAction.mockResolvedValue(existingToken);
735
+ await tokenStore.getAccessTokenSilently();
736
+
737
+ // Clear mocks and set up spy on setState
738
+ mockGetAccessTokenAction.mockClear();
739
+ mockRefreshAccessTokenAction.mockResolvedValue(existingToken); // Same token
740
+
741
+ const listener = jest.fn();
742
+ tokenStore.subscribe(listener);
743
+
744
+ // Force a silent refresh that returns the same token
745
+ await tokenStore.refreshToken();
746
+
747
+ // Verify state was updated despite same token (manual refresh always updates)
748
+ expect(listener).toHaveBeenCalled();
749
+ expect(tokenStore.getSnapshot().loading).toBe(false);
750
+ });
751
+ });
752
+
753
+ describe('TokenStore constructor', () => {
754
+ const setupMockEnv = (cookieValue = '', protocol = 'https:') => {
755
+ const mockCookieSetter = jest.fn();
756
+
757
+ Object.defineProperty(_global, 'document', {
758
+ value: { cookie: cookieValue },
759
+ writable: true,
760
+ configurable: true,
761
+ });
762
+
763
+ Object.defineProperty(document, 'cookie', {
764
+ get: () => cookieValue,
765
+ set: mockCookieSetter,
766
+ configurable: true,
767
+ });
768
+
769
+ Object.defineProperty(_global, 'window', {
770
+ value: { location: { protocol } },
771
+ writable: true,
772
+ configurable: true,
773
+ });
774
+
775
+ return mockCookieSetter;
776
+ };
777
+
778
+ it('should initialize with cookie when present', () => {
779
+ const token = 'constructor-token';
780
+ const mockSetter = setupMockEnv(`workos-access-token=${token};`);
781
+
782
+ const store = new TokenStore();
783
+
784
+ expect(mockSetter).toHaveBeenCalledWith('workos-access-token=; SameSite=Lax; Max-Age=0; Secure');
785
+ expect(store.getSnapshot().token).toBe(token);
786
+ });
787
+
788
+ it('should initialize without cookie when document is undefined', () => {
789
+ delete _global.document;
790
+
791
+ const store = new TokenStore();
792
+
793
+ expect(store.getSnapshot().token).toBeUndefined();
794
+ });
795
+
796
+ it('should initialize without cookie when no matching cookie found', () => {
797
+ setupMockEnv('other-cookie=value; different-cookie=another-value');
798
+
799
+ const store = new TokenStore();
800
+
801
+ expect(store.getSnapshot().token).toBeUndefined();
802
+ });
803
+
804
+ it('should prevent duplicate cookie consumption', async () => {
805
+ const token = 'already-consumed-token';
806
+ setupMockEnv(`workos-access-token=${token};`);
807
+
808
+ const store = new TokenStore();
809
+
810
+ // Cookie consumed during construction, getAccessToken should return cached token
811
+ const result = await store.getAccessToken();
812
+
813
+ expect(result).toBe(token);
814
+ });
815
+ });
816
+ });