@oxyhq/core 1.11.21 → 1.11.22
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/mixins/OxyServices.fedcm.js +0 -80
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -79
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -24
- package/package.json +1 -1
- package/src/mixins/OxyServices.fedcm.ts +0 -83
- package/src/mixins/__tests__/fedcm.test.ts +0 -182
|
@@ -29,14 +29,6 @@ interface FedCMTokenResult {
|
|
|
29
29
|
token: string;
|
|
30
30
|
isAutoSelected: boolean;
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
33
|
-
* Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
|
|
34
|
-
* and never cleared at runtime (a fresh page load resets it naturally), but
|
|
35
|
-
* tests sharing one module instance need to start from a clean slate.
|
|
36
|
-
*
|
|
37
|
-
* @internal
|
|
38
|
-
*/
|
|
39
|
-
export declare function __resetSilentSSOMemoForTests(): void;
|
|
40
32
|
/**
|
|
41
33
|
* Federated Credential Management (FedCM) Authentication Mixin
|
|
42
34
|
*
|
|
@@ -121,22 +113,6 @@ export declare function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(
|
|
|
121
113
|
* ```
|
|
122
114
|
*/
|
|
123
115
|
silentSignInWithFedCM(): Promise<SessionLoginResponse | null>;
|
|
124
|
-
/**
|
|
125
|
-
* Build the page-load silent-SSO memo key from the current origin and the
|
|
126
|
-
* configured API base URL. Two providers pointed at the same API from the
|
|
127
|
-
* same origin share a single silent attempt per page load.
|
|
128
|
-
*
|
|
129
|
-
* @internal
|
|
130
|
-
*/
|
|
131
|
-
silentSSOMemoKey(): string;
|
|
132
|
-
/**
|
|
133
|
-
* Perform the actual silent FedCM sign-in. Always wrapped by
|
|
134
|
-
* {@link silentSignInWithFedCM}'s page-load memo — never call this directly
|
|
135
|
-
* (doing so bypasses the run-once guard).
|
|
136
|
-
*
|
|
137
|
-
* @internal
|
|
138
|
-
*/
|
|
139
|
-
_performSilentSignInWithFedCM(): Promise<SessionLoginResponse | null>;
|
|
140
116
|
/**
|
|
141
117
|
* Request identity credential from browser using FedCM API
|
|
142
118
|
*
|
package/package.json
CHANGED
|
@@ -130,46 +130,6 @@ let fedCMRequestInProgress = false;
|
|
|
130
130
|
let fedCMRequestPromise: Promise<FedCMTokenResult | null> | null = null;
|
|
131
131
|
let currentMediationMode: string | null = null;
|
|
132
132
|
|
|
133
|
-
/**
|
|
134
|
-
* Page-load-persistent memo for SILENT FedCM sign-in.
|
|
135
|
-
*
|
|
136
|
-
* Silent SSO (`mediation: 'silent'`) is the one FedCM flow that runs WITHOUT a
|
|
137
|
-
* user gesture — on app startup / provider mount. Multiple consumers
|
|
138
|
-
* (`@oxyhq/auth`'s `WebOxyProvider` / `useWebSSO`, `@oxyhq/services`'
|
|
139
|
-
* `useWebSSO`) can each mount and trigger it, and a remount storm (route churn,
|
|
140
|
-
* React StrictMode double-invoke, error-boundary recovery) previously turned
|
|
141
|
-
* into a `navigator.credentials.get` storm. This memo collapses every silent
|
|
142
|
-
* attempt for a given `origin + baseURL` into AT MOST ONE browser credential
|
|
143
|
-
* request per page load:
|
|
144
|
-
*
|
|
145
|
-
* - the FIRST silent call runs the real flow and stores its in-flight promise;
|
|
146
|
-
* - concurrent silent calls share that same in-flight promise;
|
|
147
|
-
* - once it settles, the memo retains the resolved value (a session OR `null`)
|
|
148
|
-
* and every subsequent silent call returns it WITHOUT re-invoking the
|
|
149
|
-
* browser.
|
|
150
|
-
*
|
|
151
|
-
* Keyed on `origin + baseURL` (not the OxyServices instance) so it survives
|
|
152
|
-
* instance churn across remounts. Intentionally never cleared: only a fresh
|
|
153
|
-
* page load — which starts a fresh module scope — can change the IdP session
|
|
154
|
-
* state that silent mediation observes.
|
|
155
|
-
*
|
|
156
|
-
* This guard is SILENT-ONLY. Interactive flows (`signInWithFedCM`,
|
|
157
|
-
* `mediation: 'optional'|'required'`, `mode: 'active'|'passive'`) must always
|
|
158
|
-
* be able to re-prompt and are never memoized here.
|
|
159
|
-
*/
|
|
160
|
-
const silentSSOMemo = new Map<string, Promise<SessionLoginResponse | null>>();
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Test-only reset of the page-load silent-SSO memo. The memo is module-scoped
|
|
164
|
-
* and never cleared at runtime (a fresh page load resets it naturally), but
|
|
165
|
-
* tests sharing one module instance need to start from a clean slate.
|
|
166
|
-
*
|
|
167
|
-
* @internal
|
|
168
|
-
*/
|
|
169
|
-
export function __resetSilentSSOMemoForTests(): void {
|
|
170
|
-
silentSSOMemo.clear();
|
|
171
|
-
}
|
|
172
|
-
|
|
173
133
|
/**
|
|
174
134
|
* Federated Credential Management (FedCM) Authentication Mixin
|
|
175
135
|
*
|
|
@@ -362,49 +322,6 @@ export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
362
322
|
return null;
|
|
363
323
|
}
|
|
364
324
|
|
|
365
|
-
// Page-load run-once guard. The first silent attempt for this
|
|
366
|
-
// origin + API runs; concurrent callers share the in-flight promise; once
|
|
367
|
-
// it settles, every later caller gets the memoized result (session OR
|
|
368
|
-
// null) WITHOUT re-invoking `navigator.credentials.get`. This is the single
|
|
369
|
-
// chokepoint for silent SSO across all consumers and remounts.
|
|
370
|
-
const memoKey = this.silentSSOMemoKey();
|
|
371
|
-
const existing = silentSSOMemo.get(memoKey);
|
|
372
|
-
if (existing) {
|
|
373
|
-
debug.log('Silent SSO: Returning memoized page-load result (no re-invocation)');
|
|
374
|
-
return existing;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const attempt = this._performSilentSignInWithFedCM();
|
|
378
|
-
silentSSOMemo.set(memoKey, attempt);
|
|
379
|
-
return attempt;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Build the page-load silent-SSO memo key from the current origin and the
|
|
384
|
-
* configured API base URL. Two providers pointed at the same API from the
|
|
385
|
-
* same origin share a single silent attempt per page load.
|
|
386
|
-
*
|
|
387
|
-
* @internal
|
|
388
|
-
*/
|
|
389
|
-
public silentSSOMemoKey(): string {
|
|
390
|
-
const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
|
|
391
|
-
let baseURL = '';
|
|
392
|
-
try {
|
|
393
|
-
baseURL = this.getBaseURL();
|
|
394
|
-
} catch {
|
|
395
|
-
baseURL = '';
|
|
396
|
-
}
|
|
397
|
-
return `${origin}|${baseURL}`;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Perform the actual silent FedCM sign-in. Always wrapped by
|
|
402
|
-
* {@link silentSignInWithFedCM}'s page-load memo — never call this directly
|
|
403
|
-
* (doing so bypasses the run-once guard).
|
|
404
|
-
*
|
|
405
|
-
* @internal
|
|
406
|
-
*/
|
|
407
|
-
public async _performSilentSignInWithFedCM(): Promise<SessionLoginResponse | null> {
|
|
408
325
|
const clientId = this.getClientId();
|
|
409
326
|
debug.log('Silent SSO: Starting for', clientId);
|
|
410
327
|
|
|
@@ -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
|
-
});
|