@oxyhq/core 1.11.21 → 1.11.23
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 +6 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +52 -0
- package/dist/cjs/OxyServices.base.js +16 -0
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -80
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +52 -0
- package/dist/esm/OxyServices.base.js +16 -0
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -79
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +31 -0
- package/dist/types/OxyServices.base.d.ts +14 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +6 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -24
- package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +1 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/package.json +1 -1
- package/src/HttpService.ts +53 -0
- package/src/OxyServices.base.ts +17 -0
- package/src/mixins/OxyServices.fedcm.ts +0 -83
- package/src/mixins/__tests__/fedcm.test.ts +0 -182
- package/src/mixins/__tests__/onTokensChanged.test.ts +130 -0
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { OxyServices } from '../../OxyServices';
|
|
22
|
-
import { __resetSilentSSOMemoForTests } from '../OxyServices.fedcm';
|
|
23
22
|
|
|
24
23
|
interface CredentialGetCall {
|
|
25
24
|
identity: {
|
|
@@ -70,11 +69,6 @@ describe('OxyServices FedCM nonce binding', () => {
|
|
|
70
69
|
afterEach(() => {
|
|
71
70
|
clearBrowserGlobals();
|
|
72
71
|
jest.restoreAllMocks();
|
|
73
|
-
// The silent-SSO memo is module-scoped and survives between `it` blocks.
|
|
74
|
-
// Each test that calls `silentSignInWithFedCM` expects a fresh browser
|
|
75
|
-
// invocation, so reset the memo (a real page load would do the same by
|
|
76
|
-
// starting a fresh module scope).
|
|
77
|
-
__resetSilentSSOMemoForTests();
|
|
78
72
|
});
|
|
79
73
|
|
|
80
74
|
it('silent SSO mints a server nonce and forwards it to the browser', async () => {
|
|
@@ -231,7 +225,6 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
|
|
|
231
225
|
afterEach(() => {
|
|
232
226
|
clearBrowserGlobals();
|
|
233
227
|
jest.restoreAllMocks();
|
|
234
|
-
__resetSilentSSOMemoForTests();
|
|
235
228
|
});
|
|
236
229
|
|
|
237
230
|
it('interactive sign-in requests the modern mode: "active"', async () => {
|
|
@@ -328,178 +321,3 @@ describe('OxyServices FedCM mode enum (active/passive)', () => {
|
|
|
328
321
|
expect(call.identity.mode).toBeUndefined();
|
|
329
322
|
});
|
|
330
323
|
});
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Page-load silent-SSO run-once guard.
|
|
334
|
-
*
|
|
335
|
-
* Silent SSO must invoke `navigator.credentials.get` AT MOST ONCE per page
|
|
336
|
-
* load, even when multiple consumers / remounts / StrictMode call
|
|
337
|
-
* `silentSignInWithFedCM()` repeatedly. The guard lives at the chokepoint in
|
|
338
|
-
* core: the first silent attempt for an `origin + baseURL` runs; concurrent
|
|
339
|
-
* callers share the in-flight promise; later callers get the memoized result
|
|
340
|
-
* (session OR null) without re-invoking the browser.
|
|
341
|
-
*
|
|
342
|
-
* Interactive sign-in (`signInWithFedCM`) is NOT memoized — a user clicking the
|
|
343
|
-
* sign-in button must always be able to re-prompt. That is asserted too.
|
|
344
|
-
*/
|
|
345
|
-
describe('OxyServices FedCM silent-SSO page-load guard', () => {
|
|
346
|
-
afterEach(() => {
|
|
347
|
-
clearBrowserGlobals();
|
|
348
|
-
jest.restoreAllMocks();
|
|
349
|
-
__resetSilentSSOMemoForTests();
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it('invokes the browser at most once across repeated silent calls and returns the memoized result', async () => {
|
|
353
|
-
let getCallCount = 0;
|
|
354
|
-
installBrowserGlobals({
|
|
355
|
-
credentialsGet: async () => {
|
|
356
|
-
getCallCount += 1;
|
|
357
|
-
return { type: 'identity', token: 'idp-token', isAutoSelected: true };
|
|
358
|
-
},
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
362
|
-
let exchangeCount = 0;
|
|
363
|
-
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
364
|
-
if (url === '/fedcm/nonce') {
|
|
365
|
-
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
366
|
-
}
|
|
367
|
-
if (url === '/fedcm/exchange') {
|
|
368
|
-
exchangeCount += 1;
|
|
369
|
-
return {
|
|
370
|
-
sessionId: 'sess_guard',
|
|
371
|
-
deviceId: 'dev_guard',
|
|
372
|
-
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
373
|
-
accessToken: 'access_guard',
|
|
374
|
-
user: { id: 'user_guard', username: 'tester' },
|
|
375
|
-
} as never;
|
|
376
|
-
}
|
|
377
|
-
throw new Error(`unexpected request to ${url}`);
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
const first = await oxy.silentSignInWithFedCM();
|
|
381
|
-
const second = await oxy.silentSignInWithFedCM();
|
|
382
|
-
const third = await oxy.silentSignInWithFedCM();
|
|
383
|
-
|
|
384
|
-
// The browser credential request fired exactly once.
|
|
385
|
-
expect(getCallCount).toBe(1);
|
|
386
|
-
// The token exchange ran exactly once too (the whole flow is memoized).
|
|
387
|
-
expect(exchangeCount).toBe(1);
|
|
388
|
-
// Every caller received the same memoized session.
|
|
389
|
-
expect(first?.sessionId).toBe('sess_guard');
|
|
390
|
-
expect(second).toBe(first);
|
|
391
|
-
expect(third).toBe(first);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
it('shares a single in-flight browser call across concurrent silent callers', async () => {
|
|
395
|
-
let getCallCount = 0;
|
|
396
|
-
let resolveGet: ((value: unknown) => void) | null = null;
|
|
397
|
-
installBrowserGlobals({
|
|
398
|
-
credentialsGet: () =>
|
|
399
|
-
new Promise((resolve) => {
|
|
400
|
-
getCallCount += 1;
|
|
401
|
-
resolveGet = resolve;
|
|
402
|
-
}),
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
406
|
-
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
407
|
-
if (url === '/fedcm/nonce') {
|
|
408
|
-
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
409
|
-
}
|
|
410
|
-
if (url === '/fedcm/exchange') {
|
|
411
|
-
return {
|
|
412
|
-
sessionId: 'sess_concurrent',
|
|
413
|
-
deviceId: 'dev_concurrent',
|
|
414
|
-
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
415
|
-
accessToken: 'access_concurrent',
|
|
416
|
-
user: { id: 'user_concurrent', username: 'tester' },
|
|
417
|
-
} as never;
|
|
418
|
-
}
|
|
419
|
-
throw new Error(`unexpected request to ${url}`);
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
// Fire three silent calls before the first browser request resolves.
|
|
423
|
-
const p1 = oxy.silentSignInWithFedCM();
|
|
424
|
-
const p2 = oxy.silentSignInWithFedCM();
|
|
425
|
-
const p3 = oxy.silentSignInWithFedCM();
|
|
426
|
-
|
|
427
|
-
// Let the in-flight nonce/get chain start, then release the browser call.
|
|
428
|
-
await Promise.resolve();
|
|
429
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
430
|
-
expect(getCallCount).toBe(1);
|
|
431
|
-
expect(resolveGet).not.toBeNull();
|
|
432
|
-
(resolveGet as unknown as (value: unknown) => void)({
|
|
433
|
-
type: 'identity',
|
|
434
|
-
token: 'idp-token',
|
|
435
|
-
isAutoSelected: true,
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
|
439
|
-
|
|
440
|
-
// Only one browser invocation despite three concurrent callers.
|
|
441
|
-
expect(getCallCount).toBe(1);
|
|
442
|
-
expect(r1?.sessionId).toBe('sess_concurrent');
|
|
443
|
-
expect(r2).toBe(r1);
|
|
444
|
-
expect(r3).toBe(r1);
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('memoizes a null result (user not signed in) without re-invoking the browser', async () => {
|
|
448
|
-
let getCallCount = 0;
|
|
449
|
-
installBrowserGlobals({
|
|
450
|
-
credentialsGet: async () => {
|
|
451
|
-
getCallCount += 1;
|
|
452
|
-
return null; // user not logged in at IdP
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
457
|
-
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
458
|
-
if (url === '/fedcm/nonce') {
|
|
459
|
-
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
460
|
-
}
|
|
461
|
-
throw new Error(`unexpected request to ${url}`);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const first = await oxy.silentSignInWithFedCM();
|
|
465
|
-
const second = await oxy.silentSignInWithFedCM();
|
|
466
|
-
|
|
467
|
-
expect(first).toBeNull();
|
|
468
|
-
expect(second).toBeNull();
|
|
469
|
-
// The null verdict is memoized — the browser is not asked again.
|
|
470
|
-
expect(getCallCount).toBe(1);
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it('does NOT memoize interactive sign-in (each click can re-prompt the browser)', async () => {
|
|
474
|
-
let getCallCount = 0;
|
|
475
|
-
installBrowserGlobals({
|
|
476
|
-
credentialsGet: async () => {
|
|
477
|
-
getCallCount += 1;
|
|
478
|
-
return { type: 'identity', token: `idp-token-${getCallCount}`, isAutoSelected: false };
|
|
479
|
-
},
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
483
|
-
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method: string, url: string) => {
|
|
484
|
-
if (url === '/fedcm/nonce') {
|
|
485
|
-
return { nonce: 'n', expiresAt: new Date(Date.now() + 60000).toISOString() } as never;
|
|
486
|
-
}
|
|
487
|
-
if (url === '/fedcm/exchange') {
|
|
488
|
-
return {
|
|
489
|
-
sessionId: 'sess_interactive',
|
|
490
|
-
deviceId: 'dev_interactive',
|
|
491
|
-
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
492
|
-
accessToken: 'access_interactive',
|
|
493
|
-
user: { id: 'user_interactive', username: 'tester' },
|
|
494
|
-
} as never;
|
|
495
|
-
}
|
|
496
|
-
throw new Error(`unexpected request to ${url}`);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
await oxy.signInWithFedCM();
|
|
500
|
-
await oxy.signInWithFedCM();
|
|
501
|
-
|
|
502
|
-
// Interactive flow is never gated by the silent memo.
|
|
503
|
-
expect(getCallCount).toBe(2);
|
|
504
|
-
});
|
|
505
|
-
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxyServices.onTokensChanged` token-mirroring subscription tests.
|
|
3
|
+
*
|
|
4
|
+
* `onTokensChanged(listener)` is the single hook @oxyhq/services' OxyProvider
|
|
5
|
+
* uses to keep the shared `oxyClient` singleton's token store in lockstep with
|
|
6
|
+
* whichever OxyServices instance actually owns the session. It must fire on
|
|
7
|
+
* EVERY access-token mutation — explicit `setTokens`, `clearTokens`, and the
|
|
8
|
+
* token-planting auth flows (`verifyChallenge` / `claimSessionByToken`) which
|
|
9
|
+
* route through `setTokens` internally — passing the resulting token or `null`.
|
|
10
|
+
*
|
|
11
|
+
* These tests exercise the listener against a real OxyServices instance. The
|
|
12
|
+
* one network-dependent path (`verifyChallenge`) stubs `makeRequest` so the
|
|
13
|
+
* internal planting is observed end-to-end through the public subscription.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { OxyServices } from '../../OxyServices';
|
|
17
|
+
|
|
18
|
+
function makeOxy(): OxyServices {
|
|
19
|
+
return new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('OxyServices.onTokensChanged', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('fires with the access token on setTokens', () => {
|
|
28
|
+
const oxy = makeOxy();
|
|
29
|
+
const listener = jest.fn();
|
|
30
|
+
oxy.onTokensChanged(listener);
|
|
31
|
+
|
|
32
|
+
oxy.setTokens('access_1', 'refresh_1');
|
|
33
|
+
|
|
34
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(listener).toHaveBeenCalledWith('access_1');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('fires with null on clearTokens', () => {
|
|
39
|
+
const oxy = makeOxy();
|
|
40
|
+
oxy.setTokens('access_1');
|
|
41
|
+
|
|
42
|
+
const listener = jest.fn();
|
|
43
|
+
oxy.onTokensChanged(listener);
|
|
44
|
+
|
|
45
|
+
oxy.clearTokens();
|
|
46
|
+
|
|
47
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(listener).toHaveBeenCalledWith(null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reflects the live token on every change (set → set → clear)', () => {
|
|
52
|
+
const oxy = makeOxy();
|
|
53
|
+
const seen: Array<string | null> = [];
|
|
54
|
+
oxy.onTokensChanged((token) => seen.push(token));
|
|
55
|
+
|
|
56
|
+
oxy.setTokens('access_1');
|
|
57
|
+
oxy.setTokens('access_2');
|
|
58
|
+
oxy.clearTokens();
|
|
59
|
+
|
|
60
|
+
expect(seen).toEqual(['access_1', 'access_2', null]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stops firing after the returned unsubscribe is called', () => {
|
|
64
|
+
const oxy = makeOxy();
|
|
65
|
+
const listener = jest.fn();
|
|
66
|
+
const unsubscribe = oxy.onTokensChanged(listener);
|
|
67
|
+
|
|
68
|
+
oxy.setTokens('access_1');
|
|
69
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
70
|
+
|
|
71
|
+
unsubscribe();
|
|
72
|
+
oxy.setTokens('access_2');
|
|
73
|
+
oxy.clearTokens();
|
|
74
|
+
|
|
75
|
+
// No further notifications after unsubscribe.
|
|
76
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('notifies multiple independent listeners without clobbering', () => {
|
|
80
|
+
const oxy = makeOxy();
|
|
81
|
+
const a = jest.fn();
|
|
82
|
+
const b = jest.fn();
|
|
83
|
+
oxy.onTokensChanged(a);
|
|
84
|
+
oxy.onTokensChanged(b);
|
|
85
|
+
|
|
86
|
+
oxy.setTokens('access_1');
|
|
87
|
+
|
|
88
|
+
expect(a).toHaveBeenCalledWith('access_1');
|
|
89
|
+
expect(b).toHaveBeenCalledWith('access_1');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('isolates a throwing listener so others (and the auth flow) still proceed', () => {
|
|
93
|
+
const oxy = makeOxy();
|
|
94
|
+
const bad = jest.fn(() => {
|
|
95
|
+
throw new Error('listener boom');
|
|
96
|
+
});
|
|
97
|
+
const good = jest.fn();
|
|
98
|
+
oxy.onTokensChanged(bad);
|
|
99
|
+
oxy.onTokensChanged(good);
|
|
100
|
+
|
|
101
|
+
// Must not throw out of setTokens.
|
|
102
|
+
expect(() => oxy.setTokens('access_1')).not.toThrow();
|
|
103
|
+
expect(good).toHaveBeenCalledWith('access_1');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('fires when verifyChallenge plants the first token from /auth/verify', async () => {
|
|
107
|
+
const oxy = makeOxy();
|
|
108
|
+
const listener = jest.fn();
|
|
109
|
+
oxy.onTokensChanged(listener);
|
|
110
|
+
|
|
111
|
+
jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
|
|
112
|
+
if (url === '/auth/verify') {
|
|
113
|
+
return {
|
|
114
|
+
sessionId: 'sess_1',
|
|
115
|
+
deviceId: 'dev_1',
|
|
116
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
117
|
+
accessToken: 'access_verify',
|
|
118
|
+
refreshToken: 'refresh_verify',
|
|
119
|
+
user: { id: 'user_1', username: 'tester' },
|
|
120
|
+
} as never;
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`unexpected request to ${url}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 123, 'Device', 'fp');
|
|
126
|
+
|
|
127
|
+
// The planted token propagated through the subscription with no extra call.
|
|
128
|
+
expect(listener).toHaveBeenCalledWith('access_verify');
|
|
129
|
+
});
|
|
130
|
+
});
|