@oxyhq/core 2.3.2 → 2.4.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +8 -5
- package/dist/cjs/mixins/OxyServices.auth.js +24 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +77 -8
- package/dist/cjs/mixins/OxyServices.popup.js +16 -0
- package/dist/cjs/utils/coldBoot.js +66 -17
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +8 -5
- package/dist/esm/mixins/OxyServices.auth.js +24 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +77 -8
- package/dist/esm/mixins/OxyServices.popup.js +16 -0
- package/dist/esm/utils/coldBoot.js +66 -17
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +3 -3
- package/dist/types/AuthManagerTypes.d.ts +17 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +20 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +2 -1
- package/dist/types/utils/coldBoot.d.ts +26 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +9 -5
- package/src/AuthManagerTypes.ts +18 -0
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.auth.ts +44 -1
- package/src/mixins/OxyServices.fedcm.ts +82 -8
- package/src/mixins/OxyServices.popup.ts +17 -0
- package/src/mixins/__tests__/fedcm.test.ts +115 -2
- package/src/mixins/__tests__/popup.test.ts +67 -0
- package/src/utils/__tests__/coldBoot.test.ts +150 -0
- package/src/utils/coldBoot.ts +96 -17
package/dist/cjs/AuthManager.js
CHANGED
|
@@ -733,9 +733,10 @@ class AuthManager {
|
|
|
733
733
|
* Returns the active user on success, or `null` when neither path
|
|
734
734
|
* restored a session.
|
|
735
735
|
*/
|
|
736
|
-
async initialize() {
|
|
737
|
-
// 1. Cookie path (preferred).
|
|
738
|
-
|
|
736
|
+
async initialize(options = {}) {
|
|
737
|
+
// 1. Cookie path (preferred). Forward the optional cold-boot fail-fast
|
|
738
|
+
// timeout so a cross-domain stall cannot hang provider init.
|
|
739
|
+
const cookieResult = await this.restoreFromCookies(options);
|
|
739
740
|
if (cookieResult.accounts.length > 0) {
|
|
740
741
|
return this.currentUser;
|
|
741
742
|
}
|
|
@@ -952,7 +953,7 @@ class AuthManager {
|
|
|
952
953
|
* proceed unauthenticated. State is NOT cleared on failure; existing
|
|
953
954
|
* accounts (if any) remain intact.
|
|
954
955
|
*/
|
|
955
|
-
async restoreFromCookies() {
|
|
956
|
+
async restoreFromCookies(options = {}) {
|
|
956
957
|
// Cross-tab cascade debounce. If we restored within the last
|
|
957
958
|
// _RESTORE_DEBOUNCE_MS for the currently-active slot, skip the network
|
|
958
959
|
// round-trip and return the cached registry verbatim. A burst of N
|
|
@@ -970,7 +971,9 @@ class AuthManager {
|
|
|
970
971
|
}
|
|
971
972
|
let snapshot;
|
|
972
973
|
try {
|
|
973
|
-
|
|
974
|
+
// Forward the optional cold-boot fail-fast timeout. Undefined (the warm
|
|
975
|
+
// cross-tab cascade default) preserves the wait-indefinitely behaviour.
|
|
976
|
+
snapshot = await this.oxyServices.refreshAllSessions({ timeout: options.timeout });
|
|
974
977
|
}
|
|
975
978
|
catch {
|
|
976
979
|
return { accounts: [], activeAuthuser: null };
|
|
@@ -454,19 +454,42 @@ function OxyServicesAuthMixin(Base) {
|
|
|
454
454
|
* tokens do. Each access token still needs to be planted via
|
|
455
455
|
* `setTokens(...)` (or per-account in-memory storage) at the consumer.
|
|
456
456
|
*/
|
|
457
|
-
async refreshAllSessions() {
|
|
457
|
+
async refreshAllSessions(options = {}) {
|
|
458
458
|
const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/auth/refresh-all`;
|
|
459
|
+
// Optional bounded abort (see `RefreshAllOptions.timeout`). A positive
|
|
460
|
+
// timeout arms an `AbortController` that aborts the in-flight request; an
|
|
461
|
+
// abort is treated as "no signed-in accounts on this device" — the same
|
|
462
|
+
// outcome as a 401 — so a cross-domain stall falls through cleanly instead
|
|
463
|
+
// of hanging the cold boot.
|
|
464
|
+
const timeout = typeof options.timeout === 'number' && options.timeout > 0 ? options.timeout : undefined;
|
|
465
|
+
const controller = timeout !== undefined ? new AbortController() : undefined;
|
|
466
|
+
const timeoutId = timeout !== undefined && controller
|
|
467
|
+
? setTimeout(() => controller.abort(), timeout)
|
|
468
|
+
: undefined;
|
|
459
469
|
let response;
|
|
460
470
|
try {
|
|
461
471
|
response = await fetch(url, {
|
|
462
472
|
method: 'POST',
|
|
463
473
|
credentials: 'include',
|
|
464
474
|
headers: { Accept: 'application/json' },
|
|
475
|
+
signal: controller?.signal,
|
|
465
476
|
});
|
|
466
477
|
}
|
|
467
478
|
catch (error) {
|
|
479
|
+
// A bounded-timeout abort is the "not signed in / cross-domain stall"
|
|
480
|
+
// path, NOT an error. The browser raises a DOMException named
|
|
481
|
+
// 'AbortError' (some runtimes use a generic Error); match on the name so
|
|
482
|
+
// we never throw the timeout into the cold-boot error handler.
|
|
483
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
484
|
+
return { accounts: [] };
|
|
485
|
+
}
|
|
468
486
|
throw this.handleError(error);
|
|
469
487
|
}
|
|
488
|
+
finally {
|
|
489
|
+
if (timeoutId !== undefined) {
|
|
490
|
+
clearTimeout(timeoutId);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
470
493
|
if (response.status === 401) {
|
|
471
494
|
return { accounts: [] };
|
|
472
495
|
}
|
|
@@ -294,6 +294,20 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
294
294
|
// Optional/interactive mediation should only happen when the user clicks "Sign In".
|
|
295
295
|
let credential = null;
|
|
296
296
|
const loginHint = this.getStoredLoginHint();
|
|
297
|
+
// Fast-skip: with no stored login hint this browser has never completed a
|
|
298
|
+
// FedCM sign-in for any Oxy account, so silent mediation cannot return a
|
|
299
|
+
// credential — the IdP has nothing to silently re-issue. Doing the full
|
|
300
|
+
// round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
|
|
301
|
+
// `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
|
|
302
|
+
// pure latency in the cold-boot critical path. Return `null` immediately so
|
|
303
|
+
// the next cold-boot step (stored-session / iframe / bounce) runs without
|
|
304
|
+
// the wasted nonce mint + abort wait. A genuinely associated browser always
|
|
305
|
+
// has a hint (it is stored only after a real exchange), so this never skips
|
|
306
|
+
// a recoverable session.
|
|
307
|
+
if (!loginHint) {
|
|
308
|
+
debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
297
311
|
try {
|
|
298
312
|
// Server-minted, origin-bound nonce — required for `/fedcm/exchange`
|
|
299
313
|
// to accept the resulting ID token (anti-replay binding).
|
|
@@ -431,6 +445,34 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
431
445
|
debug.log('Request timed out after', timeoutMs, 'ms (mediation:', requestedMediation + ')');
|
|
432
446
|
controller.abort();
|
|
433
447
|
}, timeoutMs);
|
|
448
|
+
// Hard settle guarantee for the timeout path.
|
|
449
|
+
//
|
|
450
|
+
// The `setTimeout` above aborts the request's `AbortController`, which is
|
|
451
|
+
// the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
|
|
452
|
+
// rejects the awaited promise — but `navigator.credentials.get()` is a
|
|
453
|
+
// browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
|
|
454
|
+
// to settle the awaited promise in every Chrome version / internal state
|
|
455
|
+
// (the credential request can sit "pending" while the browser-side flow is
|
|
456
|
+
// stuck, ignoring the signal). If that happens, `await credentials.get(...)`
|
|
457
|
+
// never resolves OR rejects, this IIFE hangs forever, and — because this is
|
|
458
|
+
// ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
|
|
459
|
+
// the terminal `/sso` bounce never fires. That was the production hang.
|
|
460
|
+
//
|
|
461
|
+
// `settlePromise` races the credential lookup against a timer that ALWAYS
|
|
462
|
+
// resolves to `null` shortly after the abort deadline. The abort still fires
|
|
463
|
+
// first (so the browser is asked to cancel), but even if `credentials.get`
|
|
464
|
+
// never settles, the race resolves and the step falls through cleanly to the
|
|
465
|
+
// next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
|
|
466
|
+
// well-behaved browser the chance to surface its own AbortError (preserving
|
|
467
|
+
// the existing error path) before we force a clean `null`.
|
|
468
|
+
let settleTimer;
|
|
469
|
+
const settlePromise = new Promise((resolve) => {
|
|
470
|
+
const ctor = this.constructor;
|
|
471
|
+
settleTimer = setTimeout(() => {
|
|
472
|
+
debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
|
|
473
|
+
resolve(null);
|
|
474
|
+
}, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
|
|
475
|
+
});
|
|
434
476
|
// Normalise the caller's mode to the modern W3C value first. A modern
|
|
435
477
|
// browser accepts it; an older one (Chrome 125–131) rejects it with a
|
|
436
478
|
// synchronous TypeError, in which case we retry with the legacy value.
|
|
@@ -467,7 +509,13 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
467
509
|
debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
|
|
468
510
|
let credential;
|
|
469
511
|
try {
|
|
470
|
-
|
|
512
|
+
// Race the browser FedCM lookup against the hard settle guarantee so
|
|
513
|
+
// a `credentials.get` that ignores the abort signal can never hang
|
|
514
|
+
// the cold boot (see `settlePromise`).
|
|
515
|
+
credential = await Promise.race([
|
|
516
|
+
credentials.get(buildCredentialOptions(modernMode)),
|
|
517
|
+
settlePromise,
|
|
518
|
+
]);
|
|
471
519
|
}
|
|
472
520
|
catch (modeError) {
|
|
473
521
|
// Chrome 125–131 only knows the legacy 'button'/'widget' enum and
|
|
@@ -476,7 +524,10 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
476
524
|
if (modernMode && isUnknownModeEnumError(modeError)) {
|
|
477
525
|
const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
|
|
478
526
|
debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
|
|
479
|
-
credential = await
|
|
527
|
+
credential = await Promise.race([
|
|
528
|
+
credentials.get(buildCredentialOptions(legacyMode)),
|
|
529
|
+
settlePromise,
|
|
530
|
+
]);
|
|
480
531
|
}
|
|
481
532
|
else {
|
|
482
533
|
throw modeError;
|
|
@@ -503,6 +554,9 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
503
554
|
}
|
|
504
555
|
finally {
|
|
505
556
|
clearTimeout(timeout);
|
|
557
|
+
if (settleTimer !== undefined) {
|
|
558
|
+
clearTimeout(settleTimer);
|
|
559
|
+
}
|
|
506
560
|
// Only reset the shared lock if it still belongs to THIS request. When an
|
|
507
561
|
// interactive request aborts a slow silent one, the silent settles (and
|
|
508
562
|
// runs this `finally`) AFTER the interactive has already taken over the
|
|
@@ -741,12 +795,27 @@ function OxyServicesFedCMMixin(Base) {
|
|
|
741
795
|
_a.DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json',
|
|
742
796
|
_a.FEDCM_TIMEOUT = 15000 // 15 seconds for interactive
|
|
743
797
|
,
|
|
744
|
-
// Silent mediation runs on page load
|
|
745
|
-
//
|
|
746
|
-
// round-trip
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
798
|
+
// Silent mediation runs on page load as ONE step of the ordered cold-boot
|
|
799
|
+
// sequence (mint nonce → navigator.credentials.get → /fedcm/exchange). The
|
|
800
|
+
// real round-trip was measured at >3s for live users, so the budget must stay
|
|
801
|
+
// comfortably above 3s. It must ALSO be tight: on a logged-out browser this
|
|
802
|
+
// step never resolves a credential, and every millisecond it spends timing
|
|
803
|
+
// out is pure latency in front of the steps that actually hold the answer
|
|
804
|
+
// (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
|
|
805
|
+
// the floor that preserves the >3s success margin while bounding the dead
|
|
806
|
+
// wait — down from the previous 10s, which alone could account for most of a
|
|
807
|
+
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
808
|
+
_a.FEDCM_SILENT_TIMEOUT = 4000 // 4 seconds for silent mediation
|
|
750
809
|
,
|
|
810
|
+
// Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
|
|
811
|
+
// / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
|
|
812
|
+
// abort fires first; a well-behaved browser surfaces its own `AbortError`
|
|
813
|
+
// within this window (keeping the existing error path intact). If — as seen
|
|
814
|
+
// in production — `navigator.credentials.get()` ignores the abort and the
|
|
815
|
+
// awaited promise never settles, the hard settle resolves the request to
|
|
816
|
+
// `null` this many ms later, guaranteeing the cold-boot step always settles.
|
|
817
|
+
// 500ms is ample for a browser to deliver an abort rejection while keeping the
|
|
818
|
+
// worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
|
|
819
|
+
_a.FEDCM_ABORT_SETTLE_GRACE_MS = 500,
|
|
751
820
|
_a;
|
|
752
821
|
}
|
|
@@ -411,8 +411,24 @@ function OxyServicesPopupAuthMixin(Base) {
|
|
|
411
411
|
cleanup();
|
|
412
412
|
resolve(session || null);
|
|
413
413
|
};
|
|
414
|
+
// Fail-fast on a load failure. When the per-apex `/auth/silent` host is
|
|
415
|
+
// unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
|
|
416
|
+
// network drops, the iframe never posts a message — without this handler
|
|
417
|
+
// the silent restore would block for the FULL `timeout` (dead latency in
|
|
418
|
+
// the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
|
|
419
|
+
// so resolve `null` immediately and let the next cold-boot step run. The
|
|
420
|
+
// success path posts a message and is handled above; these only catch the
|
|
421
|
+
// no-message failure modes.
|
|
422
|
+
const failFast = () => {
|
|
423
|
+
cleanup();
|
|
424
|
+
resolve(null);
|
|
425
|
+
};
|
|
426
|
+
iframe.onerror = failFast;
|
|
427
|
+
iframe.onabort = failFast;
|
|
414
428
|
const cleanup = () => {
|
|
415
429
|
clearTimeout(timeoutId);
|
|
430
|
+
iframe.onerror = null;
|
|
431
|
+
iframe.onabort = null;
|
|
416
432
|
window.removeEventListener('message', messageHandler);
|
|
417
433
|
};
|
|
418
434
|
window.addEventListener('message', messageHandler);
|
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
27
|
exports.runColdBoot = runColdBoot;
|
|
28
|
+
/**
|
|
29
|
+
* The unique sentinel a step's `run()` resolves to (via the internal race)
|
|
30
|
+
* when the overall cold-boot deadline expires before that step settled. It is
|
|
31
|
+
* NOT a {@link ColdBootStepResult} — the runner detects it by identity and
|
|
32
|
+
* treats it as "this step did not settle in time; move on".
|
|
33
|
+
*
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
const DEADLINE_EXPIRED = Symbol('coldBoot.deadlineExpired');
|
|
28
37
|
/**
|
|
29
38
|
* Run the ordered cold-boot steps and resolve to the first recovered session,
|
|
30
39
|
* or `unauthenticated` if none recovers one.
|
|
@@ -41,31 +50,71 @@ exports.runColdBoot = runColdBoot;
|
|
|
41
50
|
* 4. After the loop with no winner → `{ kind: 'unauthenticated' }`.
|
|
42
51
|
*/
|
|
43
52
|
async function runColdBoot(options) {
|
|
44
|
-
const { steps, onStepError } = options;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
const { steps, onStepError, overallDeadlineMs, onStepDeadline } = options;
|
|
54
|
+
// Arm the optional overall deadline. The budget is SHARED across the whole
|
|
55
|
+
// loop (not reset per step): a single timer resolves a reusable
|
|
56
|
+
// `DEADLINE_EXPIRED` sentinel that every per-step race can observe. Once it
|
|
57
|
+
// fires, later steps race against an already-resolved promise and so never
|
|
58
|
+
// block, yet the loop keeps iterating so the terminal step still fires.
|
|
59
|
+
const deadlineMs = typeof overallDeadlineMs === 'number' &&
|
|
60
|
+
Number.isFinite(overallDeadlineMs) &&
|
|
61
|
+
overallDeadlineMs > 0
|
|
62
|
+
? overallDeadlineMs
|
|
63
|
+
: null;
|
|
64
|
+
let deadlineTimer;
|
|
65
|
+
let deadlinePromise;
|
|
66
|
+
if (deadlineMs !== null) {
|
|
67
|
+
deadlinePromise = new Promise((resolve) => {
|
|
68
|
+
deadlineTimer = setTimeout(() => resolve(DEADLINE_EXPIRED), deadlineMs);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
for (const step of steps) {
|
|
73
|
+
if (step.enabled) {
|
|
74
|
+
let isEnabled;
|
|
75
|
+
try {
|
|
76
|
+
isEnabled = step.enabled();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
onStepError?.(step.id, error);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!isEnabled)
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let result;
|
|
48
86
|
try {
|
|
49
|
-
|
|
87
|
+
// Without a deadline: legacy behaviour — await the step directly.
|
|
88
|
+
// With a deadline: race the step against the shared deadline. The
|
|
89
|
+
// step's `run()` still STARTS synchronously up to its first `await`
|
|
90
|
+
// (so a terminal step's synchronous navigation side effect always
|
|
91
|
+
// executes), but a non-settling step can no longer block the loop —
|
|
92
|
+
// the race resolves with the sentinel and we move on.
|
|
93
|
+
result = deadlinePromise
|
|
94
|
+
? await Promise.race([step.run(), deadlinePromise])
|
|
95
|
+
: await step.run();
|
|
50
96
|
}
|
|
51
97
|
catch (error) {
|
|
52
98
|
onStepError?.(step.id, error);
|
|
53
99
|
continue;
|
|
54
100
|
}
|
|
55
|
-
if (
|
|
101
|
+
if (result === DEADLINE_EXPIRED) {
|
|
102
|
+
// The deadline tripped before this step settled. Abandon the await and
|
|
103
|
+
// continue: subsequent steps race against the already-resolved deadline
|
|
104
|
+
// (so they cannot block), which lets a terminal side-effect step still
|
|
105
|
+
// run while guaranteeing the loop terminates promptly.
|
|
106
|
+
onStepDeadline?.(step.id);
|
|
56
107
|
continue;
|
|
108
|
+
}
|
|
109
|
+
if (result.kind === 'session') {
|
|
110
|
+
return { kind: 'session', via: step.id, session: result.session };
|
|
111
|
+
}
|
|
57
112
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
onStepError?.(step.id, error);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (result.kind === 'session') {
|
|
67
|
-
return { kind: 'session', via: step.id, session: result.session };
|
|
113
|
+
return { kind: 'unauthenticated' };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
if (deadlineTimer !== undefined) {
|
|
117
|
+
clearTimeout(deadlineTimer);
|
|
68
118
|
}
|
|
69
119
|
}
|
|
70
|
-
return { kind: 'unauthenticated' };
|
|
71
120
|
}
|