@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.
@@ -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
- const cookieResult = await this.restoreFromCookies();
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
- snapshot = await this.oxyServices.refreshAllSessions();
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
- credential = await credentials.get(buildCredentialOptions(modernMode));
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 credentials.get(buildCredentialOptions(legacyMode));
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 (e.g. re-signing-in a user whose stored
745
- // session was cleared after a cold-boot token fetch 401'd). The real silent
746
- // round-trip mint nonce navigator.credentials.get /fedcm/exchange was
747
- // measured to take more than 3s for live users, so a 3s budget timed out and
748
- // left them signed out on reload. 10s gives ample margin while staying bounded.
749
- _a.FEDCM_SILENT_TIMEOUT = 10000 // 10 seconds for silent mediation
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
- for (const step of steps) {
46
- if (step.enabled) {
47
- let isEnabled;
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
- isEnabled = step.enabled();
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 (!isEnabled)
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
- let result;
59
- try {
60
- result = await step.run();
61
- }
62
- catch (error) {
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
  }