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