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