@every-app/sdk 0.0.3 → 0.0.5
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/package.json +7 -3
- package/src/client/_internal/useEveryAppRouter.tsx +3 -3
- package/src/client/_internal/useEveryAppSession.tsx +1 -6
- package/src/client/session-manager.test.ts +796 -0
- package/src/client/session-manager.ts +92 -188
- package/src/server/auth-config.ts +0 -1
- package/src/server/authenticateRequest.test.ts +416 -0
- package/src/server/authenticateRequest.ts +11 -6
- package/src/server/getLocalD1Url.ts +10 -12
- package/src/server/types.ts +0 -4
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock import.meta.env before any imports
|
|
4
|
+
vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
|
|
5
|
+
|
|
6
|
+
describe("SessionManager", () => {
|
|
7
|
+
let SessionManager: typeof import("./session-manager").SessionManager;
|
|
8
|
+
let mockPostMessage: ReturnType<typeof vi.fn>;
|
|
9
|
+
let messageHandler: ((event: MessageEvent) => void) | null = null;
|
|
10
|
+
let addEventListenerSpy: ReturnType<typeof vi.fn>;
|
|
11
|
+
let removeEventListenerSpy: ReturnType<typeof vi.fn>;
|
|
12
|
+
|
|
13
|
+
// Helper function to simulate token responses from parent window
|
|
14
|
+
function simulateTokenResponse(options: {
|
|
15
|
+
token?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
expiresAt?: string;
|
|
18
|
+
requestId?: string;
|
|
19
|
+
origin?: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
}) {
|
|
22
|
+
if (!messageHandler) {
|
|
23
|
+
throw new Error("messageHandler not initialized");
|
|
24
|
+
}
|
|
25
|
+
messageHandler({
|
|
26
|
+
origin: options.origin ?? "https://gateway.example.com",
|
|
27
|
+
data: {
|
|
28
|
+
type: options.type ?? "SESSION_TOKEN_RESPONSE",
|
|
29
|
+
requestId: options.requestId ?? "test-uuid-123",
|
|
30
|
+
...(options.token !== undefined && { token: options.token }),
|
|
31
|
+
...(options.error !== undefined && { error: options.error }),
|
|
32
|
+
...(options.expiresAt !== undefined && {
|
|
33
|
+
expiresAt: options.expiresAt,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
} as MessageEvent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Helper function to simulate malformed message events (for security edge case testing)
|
|
40
|
+
function simulateMalformedMessage(origin: string | null, data: unknown) {
|
|
41
|
+
if (!messageHandler) {
|
|
42
|
+
throw new Error("messageHandler not initialized");
|
|
43
|
+
}
|
|
44
|
+
messageHandler({
|
|
45
|
+
origin,
|
|
46
|
+
data,
|
|
47
|
+
} as MessageEvent);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
vi.resetModules();
|
|
52
|
+
vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
|
|
53
|
+
|
|
54
|
+
// Mock window and postMessage
|
|
55
|
+
mockPostMessage = vi.fn();
|
|
56
|
+
messageHandler = null;
|
|
57
|
+
addEventListenerSpy = vi.fn((event: string, handler: Function) => {
|
|
58
|
+
if (event === "message") {
|
|
59
|
+
messageHandler = handler as (event: MessageEvent) => void;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
removeEventListenerSpy = vi.fn();
|
|
63
|
+
|
|
64
|
+
vi.stubGlobal("window", {
|
|
65
|
+
addEventListener: addEventListenerSpy,
|
|
66
|
+
removeEventListener: removeEventListenerSpy,
|
|
67
|
+
parent: {
|
|
68
|
+
postMessage: mockPostMessage,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
vi.stubGlobal("crypto", {
|
|
73
|
+
randomUUID: () => "test-uuid-123",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Import fresh module
|
|
77
|
+
const module = await import("./session-manager");
|
|
78
|
+
SessionManager = module.SessionManager;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.useRealTimers(); // Safe to call even if real timers are active
|
|
83
|
+
vi.unstubAllEnvs();
|
|
84
|
+
vi.unstubAllGlobals();
|
|
85
|
+
vi.restoreAllMocks();
|
|
86
|
+
messageHandler = null;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("constructor", () => {
|
|
90
|
+
it("throws error when appId is not provided", () => {
|
|
91
|
+
expect(() => new SessionManager({ appId: "" })).toThrow(
|
|
92
|
+
"[SessionManager] appId is required.",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("throws error when VITE_GATEWAY_URL is not set", async () => {
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
vi.stubEnv("VITE_GATEWAY_URL", "");
|
|
99
|
+
|
|
100
|
+
const module = await import("./session-manager");
|
|
101
|
+
|
|
102
|
+
expect(() => new module.SessionManager({ appId: "test-app" })).toThrow(
|
|
103
|
+
"[SessionManager] VITE_GATEWAY_URL env var is required.",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws error for invalid gateway URL", async () => {
|
|
108
|
+
vi.resetModules();
|
|
109
|
+
vi.stubEnv("VITE_GATEWAY_URL", "not-a-valid-url");
|
|
110
|
+
|
|
111
|
+
const module = await import("./session-manager");
|
|
112
|
+
|
|
113
|
+
expect(() => new module.SessionManager({ appId: "test-app" })).toThrow(
|
|
114
|
+
"[SessionManager] Invalid gateway URL: not-a-valid-url",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("successfully creates instance with valid config", () => {
|
|
119
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
120
|
+
|
|
121
|
+
expect(manager.appId).toBe("test-app");
|
|
122
|
+
expect(manager.parentOrigin).toBe("https://gateway.example.com");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("getTokenState", () => {
|
|
127
|
+
it("returns NO_TOKEN when no token exists", () => {
|
|
128
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
129
|
+
|
|
130
|
+
const state = manager.getTokenState();
|
|
131
|
+
|
|
132
|
+
expect(state.status).toBe("NO_TOKEN");
|
|
133
|
+
expect(state.token).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns VALID when token exists and is not expiring", async () => {
|
|
137
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
138
|
+
|
|
139
|
+
const tokenPromise = manager.requestNewToken();
|
|
140
|
+
|
|
141
|
+
simulateTokenResponse({
|
|
142
|
+
token: "valid-token",
|
|
143
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(), // Expires in 60 seconds
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await tokenPromise;
|
|
147
|
+
|
|
148
|
+
const state = manager.getTokenState();
|
|
149
|
+
|
|
150
|
+
expect(state.status).toBe("VALID");
|
|
151
|
+
expect(state.token).toBe("valid-token");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns EXPIRED when token is past expiration", async () => {
|
|
155
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
156
|
+
|
|
157
|
+
const tokenPromise = manager.requestNewToken();
|
|
158
|
+
|
|
159
|
+
simulateTokenResponse({
|
|
160
|
+
token: "expired-token",
|
|
161
|
+
expiresAt: new Date(Date.now() - 1000).toISOString(), // Already expired
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await tokenPromise;
|
|
165
|
+
|
|
166
|
+
const state = manager.getTokenState();
|
|
167
|
+
|
|
168
|
+
expect(state.status).toBe("EXPIRED");
|
|
169
|
+
expect(state.token).toBe("expired-token");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns REFRESHING when token request is in progress", async () => {
|
|
173
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
174
|
+
|
|
175
|
+
// Start a request but don't complete it yet
|
|
176
|
+
const tokenPromise = manager.requestNewToken();
|
|
177
|
+
|
|
178
|
+
// Check state while request is pending
|
|
179
|
+
const state = manager.getTokenState();
|
|
180
|
+
|
|
181
|
+
expect(state.status).toBe("REFRESHING");
|
|
182
|
+
expect(state.token).toBeNull();
|
|
183
|
+
|
|
184
|
+
// Complete the request to clean up
|
|
185
|
+
simulateTokenResponse({
|
|
186
|
+
token: "token",
|
|
187
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await tokenPromise;
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("requestNewToken", () => {
|
|
195
|
+
it("sends correct postMessage to parent", async () => {
|
|
196
|
+
const manager = new SessionManager({ appId: "my-app" });
|
|
197
|
+
|
|
198
|
+
const tokenPromise = manager.requestNewToken();
|
|
199
|
+
|
|
200
|
+
expect(mockPostMessage).toHaveBeenCalledWith(
|
|
201
|
+
{
|
|
202
|
+
type: "SESSION_TOKEN_REQUEST",
|
|
203
|
+
requestId: "test-uuid-123",
|
|
204
|
+
appId: "my-app",
|
|
205
|
+
},
|
|
206
|
+
"https://gateway.example.com",
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Complete the promise
|
|
210
|
+
simulateTokenResponse({
|
|
211
|
+
token: "new-token",
|
|
212
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await tokenPromise;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rejects when response contains error", async () => {
|
|
219
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
220
|
+
|
|
221
|
+
const tokenPromise = manager.requestNewToken();
|
|
222
|
+
|
|
223
|
+
simulateTokenResponse({ error: "Token request denied" });
|
|
224
|
+
|
|
225
|
+
await expect(tokenPromise).rejects.toThrow("Token request denied");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("rejects when response has no token", async () => {
|
|
229
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
230
|
+
|
|
231
|
+
const tokenPromise = manager.requestNewToken();
|
|
232
|
+
|
|
233
|
+
// Simulate response with no token field
|
|
234
|
+
simulateTokenResponse({});
|
|
235
|
+
|
|
236
|
+
await expect(tokenPromise).rejects.toThrow("No token in response");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("times out after MESSAGE_TIMEOUT_MS", async () => {
|
|
240
|
+
vi.useFakeTimers();
|
|
241
|
+
|
|
242
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
243
|
+
|
|
244
|
+
const tokenPromise = manager.requestNewToken();
|
|
245
|
+
|
|
246
|
+
// Fast-forward past the timeout (5000ms)
|
|
247
|
+
vi.advanceTimersByTime(5001);
|
|
248
|
+
|
|
249
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
250
|
+
"Token request timeout - parent did not respond",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("ignores messages from wrong origin (security-critical)", async () => {
|
|
255
|
+
vi.useFakeTimers();
|
|
256
|
+
|
|
257
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
258
|
+
|
|
259
|
+
const tokenPromise = manager.requestNewToken();
|
|
260
|
+
|
|
261
|
+
// Send message from wrong origin - this should be ignored
|
|
262
|
+
simulateTokenResponse({
|
|
263
|
+
token: "malicious-token",
|
|
264
|
+
origin: "https://malicious.example.com",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Should still timeout because the message was ignored
|
|
268
|
+
vi.advanceTimersByTime(5001);
|
|
269
|
+
|
|
270
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
271
|
+
"Token request timeout - parent did not respond",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("ignores messages with wrong requestId (security-critical)", async () => {
|
|
276
|
+
vi.useFakeTimers();
|
|
277
|
+
|
|
278
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
279
|
+
|
|
280
|
+
const tokenPromise = manager.requestNewToken();
|
|
281
|
+
|
|
282
|
+
// Send message with wrong requestId - this should be ignored
|
|
283
|
+
simulateTokenResponse({
|
|
284
|
+
token: "wrong-token",
|
|
285
|
+
requestId: "wrong-request-id",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Should still timeout
|
|
289
|
+
vi.advanceTimersByTime(5001);
|
|
290
|
+
|
|
291
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
292
|
+
"Token request timeout - parent did not respond",
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("ignores messages with wrong type", async () => {
|
|
297
|
+
vi.useFakeTimers();
|
|
298
|
+
|
|
299
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
300
|
+
|
|
301
|
+
const tokenPromise = manager.requestNewToken();
|
|
302
|
+
|
|
303
|
+
// Send message with wrong type
|
|
304
|
+
simulateTokenResponse({
|
|
305
|
+
token: "wrong-token",
|
|
306
|
+
type: "WRONG_MESSAGE_TYPE",
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
vi.advanceTimersByTime(5001);
|
|
310
|
+
|
|
311
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
312
|
+
"Token request timeout - parent did not respond",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("deduplicates concurrent token requests", async () => {
|
|
317
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
318
|
+
|
|
319
|
+
// Start two concurrent requests
|
|
320
|
+
const promise1 = manager.requestNewToken();
|
|
321
|
+
const promise2 = manager.requestNewToken();
|
|
322
|
+
|
|
323
|
+
// Should only send one postMessage
|
|
324
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
325
|
+
|
|
326
|
+
// Complete the request
|
|
327
|
+
simulateTokenResponse({
|
|
328
|
+
token: "shared-token",
|
|
329
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Both promises should resolve with the same token
|
|
333
|
+
const [token1, token2] = await Promise.all([promise1, promise2]);
|
|
334
|
+
|
|
335
|
+
expect(token1).toBe("shared-token");
|
|
336
|
+
expect(token2).toBe("shared-token");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe("getToken", () => {
|
|
341
|
+
it("requests new token when no token exists", async () => {
|
|
342
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
343
|
+
|
|
344
|
+
const tokenPromise = manager.getToken();
|
|
345
|
+
|
|
346
|
+
simulateTokenResponse({
|
|
347
|
+
token: "new-token",
|
|
348
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const token = await tokenPromise;
|
|
352
|
+
|
|
353
|
+
expect(token).toBe("new-token");
|
|
354
|
+
expect(mockPostMessage).toHaveBeenCalled();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("returns cached token when not expiring soon", async () => {
|
|
358
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
359
|
+
|
|
360
|
+
// First, get a token
|
|
361
|
+
const firstPromise = manager.getToken();
|
|
362
|
+
|
|
363
|
+
simulateTokenResponse({
|
|
364
|
+
token: "cached-token",
|
|
365
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(), // 60 seconds, well above 10s buffer
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await firstPromise;
|
|
369
|
+
|
|
370
|
+
// Reset mock to check if second call makes new request
|
|
371
|
+
mockPostMessage.mockClear();
|
|
372
|
+
|
|
373
|
+
// Get token again - should use cache
|
|
374
|
+
const cachedToken = await manager.getToken();
|
|
375
|
+
|
|
376
|
+
expect(cachedToken).toBe("cached-token");
|
|
377
|
+
expect(mockPostMessage).not.toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("requests new token when token is expiring soon", async () => {
|
|
381
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
382
|
+
|
|
383
|
+
// Get initial token that's about to expire
|
|
384
|
+
const firstPromise = manager.getToken();
|
|
385
|
+
|
|
386
|
+
simulateTokenResponse({
|
|
387
|
+
token: "expiring-token",
|
|
388
|
+
expiresAt: new Date(Date.now() + 5000).toISOString(), // 5 seconds, under 10s buffer
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
await firstPromise;
|
|
392
|
+
|
|
393
|
+
mockPostMessage.mockClear();
|
|
394
|
+
|
|
395
|
+
// Request new UUID for second request
|
|
396
|
+
vi.stubGlobal("crypto", {
|
|
397
|
+
randomUUID: () => "second-uuid-456",
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Get token again - should request new token since current is expiring soon
|
|
401
|
+
const secondPromise = manager.getToken();
|
|
402
|
+
|
|
403
|
+
expect(mockPostMessage).toHaveBeenCalled();
|
|
404
|
+
|
|
405
|
+
simulateTokenResponse({
|
|
406
|
+
token: "fresh-token",
|
|
407
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
408
|
+
requestId: "second-uuid-456",
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const newToken = await secondPromise;
|
|
412
|
+
|
|
413
|
+
expect(newToken).toBe("fresh-token");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("getUser", () => {
|
|
418
|
+
it("returns null when no token exists", () => {
|
|
419
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
420
|
+
|
|
421
|
+
const user = manager.getUser();
|
|
422
|
+
|
|
423
|
+
expect(user).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("returns null for malformed JWT", async () => {
|
|
427
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
428
|
+
|
|
429
|
+
const tokenPromise = manager.requestNewToken();
|
|
430
|
+
|
|
431
|
+
simulateTokenResponse({
|
|
432
|
+
token: "not-a-valid-jwt",
|
|
433
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await tokenPromise;
|
|
437
|
+
|
|
438
|
+
const user = manager.getUser();
|
|
439
|
+
|
|
440
|
+
expect(user).toBeNull();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("returns null for JWT with only two parts", async () => {
|
|
444
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
445
|
+
|
|
446
|
+
const tokenPromise = manager.requestNewToken();
|
|
447
|
+
|
|
448
|
+
simulateTokenResponse({
|
|
449
|
+
token: "header.payload", // Missing signature
|
|
450
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await tokenPromise;
|
|
454
|
+
|
|
455
|
+
const user = manager.getUser();
|
|
456
|
+
|
|
457
|
+
expect(user).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("returns null for JWT with invalid base64 payload", async () => {
|
|
461
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
462
|
+
|
|
463
|
+
const tokenPromise = manager.requestNewToken();
|
|
464
|
+
|
|
465
|
+
// Invalid base64 that will throw in atob()
|
|
466
|
+
simulateTokenResponse({
|
|
467
|
+
token: "header.!!!invalid-base64!!!.signature",
|
|
468
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await tokenPromise;
|
|
472
|
+
|
|
473
|
+
const user = manager.getUser();
|
|
474
|
+
|
|
475
|
+
expect(user).toBeNull();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("returns null for JWT with missing sub claim", async () => {
|
|
479
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
480
|
+
|
|
481
|
+
// Create a JWT-like token with no sub claim
|
|
482
|
+
const payload = btoa(JSON.stringify({ email: "test@example.com" }));
|
|
483
|
+
const fakeToken = `header.${payload}.signature`;
|
|
484
|
+
|
|
485
|
+
const tokenPromise = manager.requestNewToken();
|
|
486
|
+
|
|
487
|
+
simulateTokenResponse({
|
|
488
|
+
token: fakeToken,
|
|
489
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await tokenPromise;
|
|
493
|
+
|
|
494
|
+
const user = manager.getUser();
|
|
495
|
+
|
|
496
|
+
expect(user).toBeNull();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("extracts user info from valid JWT", async () => {
|
|
500
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
501
|
+
|
|
502
|
+
// Create a valid JWT-like token
|
|
503
|
+
const payload = btoa(
|
|
504
|
+
JSON.stringify({
|
|
505
|
+
sub: "user-123",
|
|
506
|
+
email: "test@example.com",
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
const fakeToken = `header.${payload}.signature`;
|
|
510
|
+
|
|
511
|
+
const tokenPromise = manager.requestNewToken();
|
|
512
|
+
|
|
513
|
+
simulateTokenResponse({
|
|
514
|
+
token: fakeToken,
|
|
515
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await tokenPromise;
|
|
519
|
+
|
|
520
|
+
const user = manager.getUser();
|
|
521
|
+
|
|
522
|
+
expect(user).toEqual({
|
|
523
|
+
userId: "user-123",
|
|
524
|
+
email: "test@example.com",
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("handles missing email in JWT", async () => {
|
|
529
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
530
|
+
|
|
531
|
+
const payload = btoa(
|
|
532
|
+
JSON.stringify({
|
|
533
|
+
sub: "user-123",
|
|
534
|
+
// No email field
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
const fakeToken = `header.${payload}.signature`;
|
|
538
|
+
|
|
539
|
+
const tokenPromise = manager.requestNewToken();
|
|
540
|
+
|
|
541
|
+
simulateTokenResponse({
|
|
542
|
+
token: fakeToken,
|
|
543
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await tokenPromise;
|
|
547
|
+
|
|
548
|
+
const user = manager.getUser();
|
|
549
|
+
|
|
550
|
+
expect(user).toEqual({
|
|
551
|
+
userId: "user-123",
|
|
552
|
+
email: "", // Defaults to empty string
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe("default token lifetime", () => {
|
|
558
|
+
it("uses DEFAULT_TOKEN_LIFETIME_MS when expiresAt not provided", async () => {
|
|
559
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
560
|
+
|
|
561
|
+
const tokenPromise = manager.requestNewToken();
|
|
562
|
+
|
|
563
|
+
// Simulate response with no expiresAt field
|
|
564
|
+
simulateTokenResponse({
|
|
565
|
+
token: "token-without-expiry",
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await tokenPromise;
|
|
569
|
+
|
|
570
|
+
const state = manager.getTokenState();
|
|
571
|
+
|
|
572
|
+
// Token should be valid (default lifetime is 60000ms)
|
|
573
|
+
expect(state.status).toBe("VALID");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe("security edge cases", () => {
|
|
578
|
+
it("cleans up event listener on successful response", async () => {
|
|
579
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
580
|
+
|
|
581
|
+
const tokenPromise = manager.requestNewToken();
|
|
582
|
+
|
|
583
|
+
simulateTokenResponse({
|
|
584
|
+
token: "valid-token",
|
|
585
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
await tokenPromise;
|
|
589
|
+
|
|
590
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
591
|
+
"message",
|
|
592
|
+
expect.any(Function),
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("cleans up event listener on timeout", async () => {
|
|
597
|
+
vi.useFakeTimers();
|
|
598
|
+
|
|
599
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
600
|
+
|
|
601
|
+
const tokenPromise = manager.requestNewToken();
|
|
602
|
+
|
|
603
|
+
vi.advanceTimersByTime(5001);
|
|
604
|
+
|
|
605
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
606
|
+
"Token request timeout - parent did not respond",
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
610
|
+
"message",
|
|
611
|
+
expect.any(Function),
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("cleans up event listener on error response", async () => {
|
|
616
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
617
|
+
|
|
618
|
+
const tokenPromise = manager.requestNewToken();
|
|
619
|
+
|
|
620
|
+
simulateTokenResponse({ error: "Access denied" });
|
|
621
|
+
|
|
622
|
+
await expect(tokenPromise).rejects.toThrow("Access denied");
|
|
623
|
+
|
|
624
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
|
625
|
+
"message",
|
|
626
|
+
expect.any(Function),
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("ignores messages with null origin (sandboxed iframes, file:// URLs)", async () => {
|
|
631
|
+
vi.useFakeTimers();
|
|
632
|
+
|
|
633
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
634
|
+
|
|
635
|
+
const tokenPromise = manager.requestNewToken();
|
|
636
|
+
|
|
637
|
+
// Null origin occurs with sandboxed iframes and file:// URLs
|
|
638
|
+
simulateMalformedMessage(null, {
|
|
639
|
+
type: "SESSION_TOKEN_RESPONSE",
|
|
640
|
+
requestId: "test-uuid-123",
|
|
641
|
+
token: "suspicious-token",
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// Should timeout because null origin doesn't match
|
|
645
|
+
vi.advanceTimersByTime(5001);
|
|
646
|
+
|
|
647
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
648
|
+
"Token request timeout - parent did not respond",
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("ignores messages with null event.data", async () => {
|
|
653
|
+
vi.useFakeTimers();
|
|
654
|
+
|
|
655
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
656
|
+
|
|
657
|
+
const tokenPromise = manager.requestNewToken();
|
|
658
|
+
|
|
659
|
+
// Send message with null data - should not crash
|
|
660
|
+
simulateMalformedMessage("https://gateway.example.com", null);
|
|
661
|
+
|
|
662
|
+
vi.advanceTimersByTime(5001);
|
|
663
|
+
|
|
664
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
665
|
+
"Token request timeout - parent did not respond",
|
|
666
|
+
);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("ignores messages with primitive event.data", async () => {
|
|
670
|
+
vi.useFakeTimers();
|
|
671
|
+
|
|
672
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
673
|
+
|
|
674
|
+
const tokenPromise = manager.requestNewToken();
|
|
675
|
+
|
|
676
|
+
// Send message with string data - should not crash
|
|
677
|
+
simulateMalformedMessage("https://gateway.example.com", "not an object");
|
|
678
|
+
|
|
679
|
+
// Send message with number data
|
|
680
|
+
simulateMalformedMessage("https://gateway.example.com", 12345);
|
|
681
|
+
|
|
682
|
+
vi.advanceTimersByTime(5001);
|
|
683
|
+
|
|
684
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
685
|
+
"Token request timeout - parent did not respond",
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("ignores messages with undefined event.data", async () => {
|
|
690
|
+
vi.useFakeTimers();
|
|
691
|
+
|
|
692
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
693
|
+
|
|
694
|
+
const tokenPromise = manager.requestNewToken();
|
|
695
|
+
|
|
696
|
+
// Send message with undefined data - should not crash
|
|
697
|
+
simulateMalformedMessage("https://gateway.example.com", undefined);
|
|
698
|
+
|
|
699
|
+
vi.advanceTimersByTime(5001);
|
|
700
|
+
|
|
701
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
702
|
+
"Token request timeout - parent did not respond",
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
describe("expiresAt handling", () => {
|
|
708
|
+
it("uses default lifetime when expiresAt is invalid date string", async () => {
|
|
709
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
710
|
+
|
|
711
|
+
const tokenPromise = manager.requestNewToken();
|
|
712
|
+
|
|
713
|
+
simulateTokenResponse({
|
|
714
|
+
token: "token-with-invalid-expiry",
|
|
715
|
+
expiresAt: "not-a-valid-date",
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
await tokenPromise;
|
|
719
|
+
|
|
720
|
+
const state = manager.getTokenState();
|
|
721
|
+
|
|
722
|
+
// Should be valid because it fell back to default lifetime (60s)
|
|
723
|
+
expect(state.status).toBe("VALID");
|
|
724
|
+
expect(state.token).toBe("token-with-invalid-expiry");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("uses default lifetime when expiresAt is empty string", async () => {
|
|
728
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
729
|
+
|
|
730
|
+
const tokenPromise = manager.requestNewToken();
|
|
731
|
+
|
|
732
|
+
simulateTokenResponse({
|
|
733
|
+
token: "token-with-empty-expiry",
|
|
734
|
+
expiresAt: "",
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
await tokenPromise;
|
|
738
|
+
|
|
739
|
+
const state = manager.getTokenState();
|
|
740
|
+
|
|
741
|
+
// Empty string is falsy, so should use default lifetime
|
|
742
|
+
expect(state.status).toBe("VALID");
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
describe("concurrent request error handling", () => {
|
|
747
|
+
it("propagates error to all waiting callers when request fails", async () => {
|
|
748
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
749
|
+
|
|
750
|
+
// Start three concurrent requests
|
|
751
|
+
const promise1 = manager.requestNewToken();
|
|
752
|
+
const promise2 = manager.requestNewToken();
|
|
753
|
+
const promise3 = manager.requestNewToken();
|
|
754
|
+
|
|
755
|
+
// Should only send one postMessage (deduplication)
|
|
756
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
757
|
+
|
|
758
|
+
// Simulate error response
|
|
759
|
+
simulateTokenResponse({ error: "Authentication failed" });
|
|
760
|
+
|
|
761
|
+
// All three promises should reject with the same error
|
|
762
|
+
await expect(promise1).rejects.toThrow("Authentication failed");
|
|
763
|
+
await expect(promise2).rejects.toThrow("Authentication failed");
|
|
764
|
+
await expect(promise3).rejects.toThrow("Authentication failed");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("allows new request after previous request failed", async () => {
|
|
768
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
769
|
+
|
|
770
|
+
// First request fails
|
|
771
|
+
const failedPromise = manager.requestNewToken();
|
|
772
|
+
simulateTokenResponse({ error: "Temporary failure" });
|
|
773
|
+
await expect(failedPromise).rejects.toThrow("Temporary failure");
|
|
774
|
+
|
|
775
|
+
// Reset mock and UUID for second request
|
|
776
|
+
mockPostMessage.mockClear();
|
|
777
|
+
vi.stubGlobal("crypto", {
|
|
778
|
+
randomUUID: () => "second-uuid-456",
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Second request should work
|
|
782
|
+
const successPromise = manager.requestNewToken();
|
|
783
|
+
|
|
784
|
+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
|
|
785
|
+
|
|
786
|
+
simulateTokenResponse({
|
|
787
|
+
token: "success-token",
|
|
788
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
789
|
+
requestId: "second-uuid-456",
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const token = await successPromise;
|
|
793
|
+
expect(token).toBe("success-token");
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
});
|