@workos-inc/authkit-nextjs 2.6.0 → 2.7.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.
- package/README.md +124 -29
- package/dist/esm/components/tokenStore.js +110 -11
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +6 -1
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/cookie.js +51 -0
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/middleware.js +2 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/session.js +35 -2
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +57 -0
- package/dist/esm/test-helpers.js.map +1 -0
- package/dist/esm/types/components/tokenStore.d.ts +7 -2
- package/dist/esm/types/cookie.d.ts +1 -0
- package/dist/esm/types/interfaces.d.ts +2 -0
- package/dist/esm/types/middleware.d.ts +1 -1
- package/dist/esm/types/session.d.ts +1 -1
- package/dist/esm/types/test-helpers.d.ts +3 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/workos.js +1 -1
- package/package.json +4 -3
- package/src/actions.spec.ts +100 -0
- package/src/auth.spec.ts +347 -0
- package/src/authkit-callback-route.spec.ts +258 -0
- package/src/components/authkit-provider.spec.tsx +471 -0
- package/src/components/button.spec.tsx +46 -0
- package/src/components/impersonation.spec.tsx +134 -0
- package/src/components/min-max-button.spec.tsx +60 -0
- package/src/components/tokenStore.spec.ts +816 -0
- package/src/components/tokenStore.ts +147 -12
- package/src/components/useAccessToken.spec.tsx +731 -0
- package/src/components/useAccessToken.ts +6 -1
- package/src/components/useTokenClaims.spec.tsx +194 -0
- package/src/cookie.spec.ts +276 -0
- package/src/cookie.ts +56 -0
- package/src/get-authorization-url.spec.ts +60 -0
- package/src/interfaces.ts +2 -0
- package/src/jwt.spec.ts +159 -0
- package/src/middleware.ts +2 -1
- package/src/session.spec.ts +1162 -0
- package/src/session.ts +41 -1
- package/src/test-helpers.ts +70 -0
- package/src/utils.spec.ts +142 -0
- package/src/workos.spec.ts +67 -0
- 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
|
+
});
|