@oxyhq/core 2.3.1 → 2.4.0

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).
@@ -741,12 +755,17 @@ function OxyServicesFedCMMixin(Base) {
741
755
  _a.DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json',
742
756
  _a.FEDCM_TIMEOUT = 15000 // 15 seconds for interactive
743
757
  ,
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
758
+ // Silent mediation runs on page load as ONE step of the ordered cold-boot
759
+ // sequence (mint nonce navigator.credentials.get /fedcm/exchange). The
760
+ // real round-trip was measured at >3s for live users, so the budget must stay
761
+ // comfortably above 3s. It must ALSO be tight: on a logged-out browser this
762
+ // step never resolves a credential, and every millisecond it spends timing
763
+ // out is pure latency in front of the steps that actually hold the answer
764
+ // (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
765
+ // the floor that preserves the >3s success margin while bounding the dead
766
+ // wait — down from the previous 10s, which alone could account for most of a
767
+ // 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
768
+ _a.FEDCM_SILENT_TIMEOUT = 4000 // 4 seconds for silent mediation
750
769
  ,
751
770
  _a;
752
771
  }
@@ -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);
@@ -94,10 +94,16 @@ function parseSsoReturnFragment(hash) {
94
94
  * outcome-independent attempted-flag (the load2 half of the loop proof).
95
95
  * - A throwing exchange is caught, reported via `onExchangeError`, and
96
96
  * treated exactly like "no session" (never loops, never rethrows).
97
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
98
- * destination is restored from the DEST key same-origin only (an
99
- * attacker-planted cross-origin or relative-evil dest is rejected). The
100
- * DEST key is removed unconditionally.
97
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
98
+ * failed-exchange, no-sessionId) not just okif the page landed on
99
+ * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
100
+ * from the DEST key so the user is never stranded on the internal callback
101
+ * path. Same-origin only (an attacker-planted cross-origin or relative-evil
102
+ * dest is rejected). The DEST key is removed unconditionally.
103
+ * - After a same-origin dest restore (which uses `history.replaceState`, that
104
+ * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
105
+ * URL-driven routers (Expo Router / React Navigation web) re-sync to the
106
+ * restored route. It is NOT dispatched when the dest is rejected/absent.
101
107
  *
102
108
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
103
109
  *
@@ -116,6 +122,21 @@ async function consumeSsoReturn(oxy, deps = {}) {
116
122
  const location = deps.location ?? window.location;
117
123
  const history = deps.history ?? window.history;
118
124
  const onExchangeError = deps.onExchangeError;
125
+ // Default: emit a synthetic `popstate` so URL-driven routers re-sync after a
126
+ // `history.replaceState` (which does NOT emit `popstate` on its own). Feature-
127
+ // detected end to end so it never throws in any environment.
128
+ const dispatchPopState = deps.dispatchPopState ??
129
+ (() => {
130
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
131
+ return;
132
+ }
133
+ if (typeof PopStateEvent !== 'undefined') {
134
+ window.dispatchEvent(new PopStateEvent('popstate'));
135
+ }
136
+ else if (typeof Event !== 'undefined') {
137
+ window.dispatchEvent(new Event('popstate'));
138
+ }
139
+ });
119
140
  const ret = parseSsoReturnFragment(location.hash);
120
141
  if (!ret) {
121
142
  // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
@@ -138,16 +159,42 @@ async function consumeSsoReturn(oxy, deps = {}) {
138
159
  // even if some consumer path skipped setting it pre-bounce.
139
160
  storage.setItem((0, ssoBounce_1.ssoAttemptedKey)(origin), '1');
140
161
  };
162
+ // Restore the user's real pre-bounce destination so they are never stranded
163
+ // on the internal callback path — invoked on EVERY consumed outcome, not just
164
+ // success. Same-origin only — never honour a cross-origin/protocol-relative
165
+ // dest that could have been planted to redirect the user. The DEST key is
166
+ // removed unconditionally. After a successful same-origin restore a synthetic
167
+ // `popstate` is dispatched so URL-driven routers re-sync.
168
+ const restoreDest = () => {
169
+ if (location.pathname === ssoBounce_1.SSO_CALLBACK_PATH) {
170
+ const dest = storage.getItem((0, ssoBounce_1.ssoDestKey)(origin));
171
+ if (dest) {
172
+ try {
173
+ const destUrl = new URL(dest, origin);
174
+ if (destUrl.origin === origin) {
175
+ history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
176
+ dispatchPopState();
177
+ }
178
+ }
179
+ catch {
180
+ // Malformed stored destination — leave the URL on the callback path.
181
+ }
182
+ }
183
+ }
184
+ storage.removeItem((0, ssoBounce_1.ssoDestKey)(origin));
185
+ };
141
186
  if (ret.kind === 'none' || ret.kind === 'error') {
142
187
  // The central IdP had no session (or the bounce failed). Record it so we do
143
188
  // not bounce again this tab — the definitive loop breaker.
144
189
  markNoSession();
190
+ restoreDest();
145
191
  return null;
146
192
  }
147
193
  if (!stateOk || !ret.code) {
148
194
  // Forged / replayed / stale fragment, or a malformed ok with no code. Treat
149
195
  // exactly like "no session": never exchange, never loop.
150
196
  markNoSession();
197
+ restoreDest();
151
198
  return null;
152
199
  }
153
200
  let session;
@@ -157,31 +204,14 @@ async function consumeSsoReturn(oxy, deps = {}) {
157
204
  catch (error) {
158
205
  onExchangeError?.(error);
159
206
  markNoSession();
207
+ restoreDest();
160
208
  return null;
161
209
  }
162
210
  if (!session?.sessionId) {
163
211
  markNoSession();
212
+ restoreDest();
164
213
  return null;
165
214
  }
166
- // If we landed on the internal callback path, restore the user's real
167
- // destination (captured at bounce time). Same-origin only — never honour a
168
- // cross-origin destination that could have been planted to redirect the
169
- // freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
170
- // and is still re-checked against the page origin.
171
- if (location.pathname === ssoBounce_1.SSO_CALLBACK_PATH) {
172
- const dest = storage.getItem((0, ssoBounce_1.ssoDestKey)(origin));
173
- if (dest) {
174
- try {
175
- const destUrl = new URL(dest, origin);
176
- if (destUrl.origin === origin) {
177
- history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
178
- }
179
- }
180
- catch {
181
- // Malformed stored destination — leave the URL on the callback path.
182
- }
183
- }
184
- }
185
- storage.removeItem((0, ssoBounce_1.ssoDestKey)(origin));
215
+ restoreDest();
186
216
  return session;
187
217
  }