@oxyhq/services 8.1.2 → 8.3.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.
Files changed (33) hide show
  1. package/lib/commonjs/ui/components/OxyProvider.js +10 -3
  2. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  3. package/lib/commonjs/ui/context/OxyContext.js +499 -79
  4. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  5. package/lib/commonjs/ui/hooks/useSessionManagement.js +7 -3
  6. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  7. package/lib/commonjs/ui/utils/ssoBounce.js +158 -0
  8. package/lib/commonjs/ui/utils/ssoBounce.js.map +1 -0
  9. package/lib/module/ui/components/OxyProvider.js +10 -3
  10. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  11. package/lib/module/ui/context/OxyContext.js +499 -79
  12. package/lib/module/ui/context/OxyContext.js.map +1 -1
  13. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  14. package/lib/module/ui/hooks/useSessionManagement.js +7 -3
  15. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  16. package/lib/module/ui/utils/ssoBounce.js +148 -0
  17. package/lib/module/ui/utils/ssoBounce.js.map +1 -0
  18. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/ui/utils/ssoBounce.d.ts +89 -0
  22. package/lib/typescript/commonjs/ui/utils/ssoBounce.d.ts.map +1 -0
  23. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  24. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  25. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  26. package/lib/typescript/module/ui/utils/ssoBounce.d.ts +89 -0
  27. package/lib/typescript/module/ui/utils/ssoBounce.d.ts.map +1 -0
  28. package/package.json +2 -2
  29. package/src/ui/components/OxyProvider.tsx +4 -3
  30. package/src/ui/context/OxyContext.tsx +513 -87
  31. package/src/ui/context/hooks/useAuthOperations.ts +1 -1
  32. package/src/ui/hooks/useSessionManagement.ts +8 -4
  33. package/src/ui/utils/ssoBounce.ts +146 -0
@@ -7,6 +7,8 @@ exports.useOxy = exports.default = exports.OxyProvider = exports.OxyContextProvi
7
7
  var _react = require("react");
8
8
  var _core = require("@oxyhq/core");
9
9
  var _bloom = require("@oxyhq/bloom");
10
+ var _ssoBounce = _interopRequireWildcard(require("../utils/ssoBounce.js"));
11
+ var ssoBounce = _ssoBounce;
10
12
  var _authStore = require("../stores/authStore.js");
11
13
  var _shallow = require("zustand/react/shallow");
12
14
  var _useSessionSocket = require("../hooks/useSessionSocket.js");
@@ -24,12 +26,78 @@ var _useAvatarPicker = require("../hooks/useAvatarPicker.js");
24
26
  var _accountStore = require("../stores/accountStore.js");
25
27
  var _useWebSSO = require("../hooks/useWebSSO.js");
26
28
  var _jsxRuntime = require("react/jsx-runtime");
29
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
27
30
  const OxyContext = /*#__PURE__*/(0, _react.createContext)(null);
28
31
 
29
32
  // Active-authuser persistence helpers (web localStorage; native no-op) live in
30
33
  // `../utils/activeAuthuser` so the session-management and auth-operations hooks
31
34
  // can share them without re-importing this 1k-line context file.
32
35
 
36
+ /**
37
+ * Module-level run-once guard for the cold-boot `fedcm-silent` step.
38
+ *
39
+ * The FedCM silent step triggers a one-shot `navigator.credentials.get`
40
+ * handshake that must fire AT MOST ONCE per page load — otherwise a provider
41
+ * remount storm (route churn, StrictMode double-invoke, error-boundary
42
+ * recovery) becomes a credential request storm. A per-instance ref resets on
43
+ * every remount, so the guard must live at module scope. Keyed on
44
+ * `origin|baseURL` so two providers pointed at the same API from the same
45
+ * origin share one attempt; never cleared because only a fresh page load can
46
+ * change the central IdP session state, and a fresh page load starts a fresh
47
+ * module scope.
48
+ *
49
+ * This is a dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
50
+ * (which guards the post-boot INTERACTIVE button path) and never a core
51
+ * module-level singleton (that re-evaluates under Metro web bundling and the
52
+ * guard would not hold).
53
+ */
54
+ const servicesSilentAttempted = new Set();
55
+
56
+ /**
57
+ * Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
58
+ */
59
+ function silentColdBootKey(oxyServices) {
60
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
61
+ let baseURL = '';
62
+ try {
63
+ baseURL = oxyServices.getBaseURL?.() ?? '';
64
+ } catch {
65
+ baseURL = '';
66
+ }
67
+ return `${origin}|${baseURL}`;
68
+ }
69
+
70
+ /**
71
+ * Whether `idpOrigin` is a same-site, first-party host of the current page —
72
+ * i.e. it shares the page's registrable apex (last two labels), so a "no
73
+ * session" answer from its `/auth/session-check` iframe is authoritative for
74
+ * THIS app and may force a local sign-out.
75
+ *
76
+ * On a cross-site IdP (or any host whose relationship to the page can't be
77
+ * positively established) this returns `false`, so the visibility-driven check
78
+ * may surface a session-ended toast but MUST NOT clear local state — a
79
+ * third-party / undetermined IdP answer can never force logout. Returns `false`
80
+ * off-browser.
81
+ */
82
+ function isSameSiteIdP(idpOrigin) {
83
+ if (typeof window === 'undefined') return false;
84
+ let idpHostname;
85
+ try {
86
+ idpHostname = new URL(idpOrigin).hostname;
87
+ } catch {
88
+ return false;
89
+ }
90
+ const pageHostname = window.location.hostname;
91
+ if (!idpHostname || !pageHostname) return false;
92
+ if (idpHostname === pageHostname) return true;
93
+ const apexOf = hostname => hostname.split('.').slice(-2).join('.');
94
+ const pageApex = apexOf(pageHostname);
95
+ // Require a real registrable apex (≥2 labels) AND an exact apex match AND that
96
+ // the IdP host is the page apex itself or a subdomain of it.
97
+ if (pageHostname.split('.').length < 2) return false;
98
+ if (apexOf(idpHostname) !== pageApex) return false;
99
+ return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
100
+ }
33
101
  let cachedUseFollowHook = null;
34
102
  const loadUseFollowHook = () => {
35
103
  if (cachedUseFollowHook) {
@@ -71,9 +139,19 @@ const OxyProvider = ({
71
139
  if (providedOxyServices) {
72
140
  oxyServicesRef.current = providedOxyServices;
73
141
  } else if (baseURL) {
142
+ // Target the CENTRAL IdP for TRUE cross-domain SSO. Every RP
143
+ // (mention.earth, homiio.com, alia.onl, …) delegates to the one central
144
+ // `auth.oxy.so` — it owns the host-only `fedcm_session` cookie and the
145
+ // central session store reached via `api.oxy.so`, so a single sign-in
146
+ // there is observed by all RPs through the opaque-code `/sso` bounce.
147
+ // `resolveCentralAuthUrl(authWebUrl)` returns the explicit `authWebUrl`
148
+ // prop when provided (explicit always wins) and the central default
149
+ // otherwise. This is NOT per-apex auto-detection — central SSO is
150
+ // deliberately central. A consumer-provided `OxyServices` instance is
151
+ // never mutated; only the baseURL-only construction path applies this.
74
152
  oxyServicesRef.current = new _core.OxyServices({
75
153
  baseURL,
76
- authWebUrl,
154
+ authWebUrl: (0, _core.resolveCentralAuthUrl)(authWebUrl),
77
155
  authRedirectUri
78
156
  });
79
157
  } else {
@@ -138,7 +216,9 @@ const OxyProvider = ({
138
216
  }, [oxyServices]);
139
217
  const logger = (0, _react.useCallback)((message, err) => {
140
218
  if (__DEV__) {
141
- console.warn(`[OxyContext] ${message}`, err);
219
+ _core.logger.warn(message, {
220
+ component: 'OxyContext'
221
+ }, err);
142
222
  }
143
223
  }, []);
144
224
  const storageKeys = (0, _react.useMemo)(() => (0, _storageHelpers.getStorageKeys)(storageKeyPrefix), [storageKeyPrefix]);
@@ -357,6 +437,14 @@ const OxyProvider = ({
357
437
  const onAuthStateChangeRef = (0, _react.useRef)(onAuthStateChange);
358
438
  onAuthStateChangeRef.current = onAuthStateChange;
359
439
 
440
+ // `handleWebSSOSession` is declared further down (it depends on values that
441
+ // are only available there). The FedCM/iframe cold-boot steps need to commit
442
+ // a recovered session through it, so we route the call through a ref that is
443
+ // populated once the callback exists. The ref is assigned synchronously on
444
+ // every render before the cold-boot effect can fire (the effect is gated on
445
+ // `storage` + `initialized`, both of which settle after first render).
446
+ const handleWebSSOSessionRef = (0, _react.useRef)(null);
447
+
360
448
  // Cold-boot session restore via the secure refresh cookies (web only).
361
449
  //
362
450
  // Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
@@ -451,90 +539,363 @@ const OxyProvider = ({
451
539
  onAuthStateChangeRef.current?.(fullUser);
452
540
  return true;
453
541
  }, [oxyServices, persistSessionDurably]);
542
+
543
+ // Native (and offline) stored-session restore — the ONLY restore path that
544
+ // runs on React Native, and the web fallback when no cross-domain step won.
545
+ //
546
+ // Verbatim-extracted from the previous `restoreSessionsFromStorage` body: it
547
+ // reads the durable `session_ids` / `active_session_id` slots, validates each
548
+ // stored session in parallel (bearer `validateSession`), and switches to the
549
+ // stored active session via the session-management `switchSession`. This body
550
+ // is platform-agnostic and gated by NO `enabled()` predicate so it runs on
551
+ // every platform — on native it is reached unconditionally (every web-only
552
+ // step ahead of it is disabled by `isWebBrowser()`), so native restore is
553
+ // exactly this and nothing else (no FedCM / iframe / refresh-all /
554
+ // handleAuthCallback).
555
+ const restoreStoredSession = (0, _react.useCallback)(async () => {
556
+ if (!storage) {
557
+ return false;
558
+ }
559
+ const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
560
+ const storedSessionIds = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
561
+ const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
562
+ let validSessions = [];
563
+ if (storedSessionIds.length > 0) {
564
+ // Validate all sessions in parallel (with 8s timeout per session) to avoid
565
+ // sequential blocking that freezes the app on startup
566
+ const VALIDATION_TIMEOUT = 8000;
567
+ const results = await Promise.allSettled(storedSessionIds.map(async sessionId => {
568
+ const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), VALIDATION_TIMEOUT));
569
+ const validationPromise = oxyServices.validateSession(sessionId, {
570
+ useHeaderValidation: true
571
+ }).catch(validationError => {
572
+ if (!(0, _errorHandlers.isInvalidSessionError)(validationError) && !(0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) {
573
+ logger('Session validation failed during init', validationError);
574
+ } else if (__DEV__ && (0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) {
575
+ _core.logger.debug('Session validation timeout (expected when offline)', {
576
+ component: 'OxyContext',
577
+ method: 'restoreStoredSession'
578
+ }, validationError);
579
+ }
580
+ return null;
581
+ });
582
+ return Promise.race([validationPromise, timeoutPromise]).then(validation => {
583
+ if (validation?.valid && validation.user) {
584
+ const now = new Date();
585
+ return {
586
+ sessionId,
587
+ deviceId: '',
588
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
589
+ lastActive: now.toISOString(),
590
+ userId: validation.user.id?.toString() ?? '',
591
+ isCurrent: sessionId === storedActiveSessionId
592
+ };
593
+ }
594
+ return null;
595
+ });
596
+ }));
597
+ validSessions = results.filter(r => r.status === 'fulfilled').map(r => r.value).filter(s => s !== null);
598
+
599
+ // Always persist validated sessions to storage (even empty list)
600
+ // to clear stale/expired session IDs that would cause 401 loops on restart
601
+ updateSessionsRef.current(validSessions, {
602
+ merge: false
603
+ });
604
+ }
605
+ if (storedActiveSessionId) {
606
+ try {
607
+ await switchSessionRef.current(storedActiveSessionId);
608
+ return true;
609
+ } catch (switchError) {
610
+ // Silently handle expected errors (invalid sessions, timeouts, network issues)
611
+ if ((0, _errorHandlers.isInvalidSessionError)(switchError)) {
612
+ await storage.removeItem(storageKeys.activeSessionId);
613
+ updateSessionsRef.current(validSessions.filter(session => session.sessionId !== storedActiveSessionId), {
614
+ merge: false
615
+ });
616
+ // Don't log expected session errors during restoration
617
+ } else if ((0, _errorHandlers.isTimeoutOrNetworkError)(switchError)) {
618
+ // Timeout/network error - non-critical, don't block
619
+ if (__DEV__) {
620
+ _core.logger.debug('Active session validation timeout (expected when offline)', {
621
+ component: 'OxyContext',
622
+ method: 'restoreStoredSession'
623
+ }, switchError);
624
+ }
625
+ } else {
626
+ // Only log unexpected errors
627
+ logger('Active session validation error', switchError);
628
+ }
629
+ }
630
+ }
631
+ return false;
632
+ }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds]);
633
+
634
+ // Central cross-domain SSO return handler (web). Parses the IdP redirect
635
+ // fragment, validates the CSRF `state`, exchanges the opaque single-use code
636
+ // for the real session, commits it, and restores the user's pre-bounce
637
+ // destination. Shared by the `sso-return` cold-boot step AND the bfcache
638
+ // `pageshow` re-evaluation, so the same security-critical logic runs exactly
639
+ // once per delivered fragment regardless of how the page was (re)shown.
640
+ //
641
+ // Returns `true` when a session was committed (caller short-circuits), `false`
642
+ // otherwise. On ANY non-ok outcome — `none`/`error`, state mismatch, missing
643
+ // code, or a failed/forged exchange — it sets the per-origin NO_SESSION flag
644
+ // so `sso-bounce` is disabled and the page cannot loop. Off-browser it is a
645
+ // no-op returning `false` (native never reaches it).
646
+ const runSsoReturn = (0, _react.useCallback)(async () => {
647
+ if (!(0, _useWebSSO.isWebBrowser)()) {
648
+ return false;
649
+ }
650
+ const ret = (0, _core.parseSsoReturnFragment)(window.location.hash);
651
+ if (!ret) {
652
+ // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
653
+ return false;
654
+ }
655
+ const origin = window.location.origin;
656
+ const expectedState = window.sessionStorage.getItem((0, _ssoBounce.ssoStateKey)(origin));
657
+ const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
658
+
659
+ // Strip the fragment FIRST so the opaque code never lingers in the address
660
+ // bar, history, or a copy-paste — even if a later step throws.
661
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
662
+ window.sessionStorage.removeItem((0, _ssoBounce.ssoStateKey)(origin));
663
+ const markNoSession = () => {
664
+ window.sessionStorage.setItem((0, _ssoBounce.ssoNoSessionKey)(origin), '1');
665
+ };
666
+ if (ret.kind === 'none' || ret.kind === 'error') {
667
+ // The central IdP had no session (or the bounce failed). Record it so we
668
+ // do not bounce again this tab — the definitive loop breaker.
669
+ markNoSession();
670
+ return false;
671
+ }
672
+ if (!stateOk || !ret.code) {
673
+ // Forged / replayed / stale fragment, or a malformed ok with no code.
674
+ // Treat exactly like "no session": never exchange, never loop.
675
+ markNoSession();
676
+ return false;
677
+ }
678
+ const commitWebSession = handleWebSSOSessionRef.current;
679
+ let session;
680
+ try {
681
+ session = await oxyServices.exchangeSsoCode(ret.code);
682
+ } catch (error) {
683
+ if (__DEV__) {
684
+ _core.logger.debug('SSO code exchange failed (treating as no session)', {
685
+ component: 'OxyContext',
686
+ method: 'runSsoReturn'
687
+ }, error);
688
+ }
689
+ markNoSession();
690
+ return false;
691
+ }
692
+ if (!session?.sessionId || !commitWebSession) {
693
+ markNoSession();
694
+ return false;
695
+ }
696
+ await commitWebSession(session);
697
+
698
+ // Restore the user's real destination captured before the bounce. We only
699
+ // rewrite the URL when we are sitting on the callback path — otherwise the
700
+ // current URL is already the destination.
701
+ if (window.location.pathname === _ssoBounce.SSO_CALLBACK_PATH) {
702
+ const dest = window.sessionStorage.getItem((0, _ssoBounce.ssoDestKey)(origin));
703
+ if (dest) {
704
+ try {
705
+ const destUrl = new URL(dest);
706
+ // Same-origin only — never honour a cross-origin destination that
707
+ // could have been planted to redirect the freshly signed-in user.
708
+ if (destUrl.origin === origin) {
709
+ window.history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
710
+ }
711
+ } catch {
712
+ // Malformed stored destination — leave the URL on the callback path.
713
+ }
714
+ }
715
+ }
716
+ window.sessionStorage.removeItem((0, _ssoBounce.ssoDestKey)(origin));
717
+ return true;
718
+ }, [oxyServices]);
719
+
720
+ // Cold boot — the single, ordered, short-circuit session-recovery sequence,
721
+ // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
722
+ // FIRST step that yields a session wins; every later step is skipped. Each
723
+ // web-only step is gated by `isWebBrowser()`, so on native ONLY
724
+ // `stored-session` runs.
725
+ //
726
+ // Order (web): redirect callback → SSO return → FedCM silent → cookie restore
727
+ // → stored session → SSO bounce (terminal). Order (native): stored session
728
+ // only (every web-only step is disabled off-browser).
454
729
  const restoreSessionsFromStorage = (0, _react.useCallback)(async () => {
455
730
  if (!storage) {
456
731
  return;
457
732
  }
458
733
  setTokenReady(false);
734
+ const commitWebSession = handleWebSSOSessionRef.current;
735
+ const silentKey = silentColdBootKey(oxyServices);
736
+ const fedcmSupported = (0, _useWebSSO.isWebBrowser)() && oxyServices.isFedCMSupported?.() === true;
459
737
  try {
460
- // Web cold-boot fast path: restore the active session from the secure
461
- // httpOnly refresh cookie before the bearer-protected stored-session
462
- // validation (which 401s on a hard reload). On success we are signed in
463
- // from the cookie alone no FedCM needed. On failure we fall through to
464
- // the existing stored-session flow below; nothing is cleared.
465
- if (await restoreViaRefreshCookie()) {
466
- return;
467
- }
468
- const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
469
- const storedSessionIds = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
470
- const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
471
- let validSessions = [];
472
- if (storedSessionIds.length > 0) {
473
- // Validate all sessions in parallel (with 8s timeout per session) to avoid
474
- // sequential blocking that freezes the app on startup
475
- const VALIDATION_TIMEOUT = 8000;
476
- const results = await Promise.allSettled(storedSessionIds.map(async sessionId => {
477
- const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), VALIDATION_TIMEOUT));
478
- const validationPromise = oxyServices.validateSession(sessionId, {
479
- useHeaderValidation: true
480
- }).catch(validationError => {
481
- if (!(0, _errorHandlers.isInvalidSessionError)(validationError) && !(0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) {
482
- logger('Session validation failed during init', validationError);
483
- } else if (__DEV__ && (0, _errorHandlers.isTimeoutOrNetworkError)(validationError)) {
484
- _core.logger.debug('Session validation timeout (expected when offline)', {
485
- component: 'OxyContext',
486
- method: 'restoreSessionsFromStorage'
487
- }, validationError);
488
- }
489
- return null;
490
- });
491
- return Promise.race([validationPromise, timeoutPromise]).then(validation => {
492
- if (validation?.valid && validation.user) {
493
- const now = new Date();
738
+ const outcome = await (0, _core.runColdBoot)({
739
+ steps: [{
740
+ // 0) Redirect callback wins: a popup/redirect sign-in just landed
741
+ // back on this page with `access_token`/`session_id` query params.
742
+ // `handleAuthCallback` plants the token but returns a PLACEHOLDER
743
+ // user (empty id), so we hydrate the REAL user via `getCurrentUser`
744
+ // and commit through `handleWebSSOSession` before claiming a
745
+ // session — never expose a placeholder user (R4).
746
+ id: 'redirect',
747
+ enabled: () => (0, _useWebSSO.isWebBrowser)(),
748
+ run: async () => {
749
+ const callbackSession = oxyServices.handleAuthCallback?.();
750
+ if (!callbackSession || !commitWebSession) {
494
751
  return {
495
- sessionId,
496
- deviceId: '',
497
- expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
498
- lastActive: now.toISOString(),
499
- userId: validation.user.id?.toString() ?? '',
500
- isCurrent: sessionId === storedActiveSessionId
752
+ kind: 'skip'
501
753
  };
502
754
  }
503
- return null;
504
- });
505
- }));
506
- validSessions = results.filter(r => r.status === 'fulfilled').map(r => r.value).filter(s => s !== null);
507
-
508
- // Always persist validated sessions to storage (even empty list)
509
- // to clear stale/expired session IDs that would cause 401 loops on restart
510
- updateSessionsRef.current(validSessions, {
511
- merge: false
512
- });
513
- }
514
- if (storedActiveSessionId) {
515
- try {
516
- await switchSessionRef.current(storedActiveSessionId);
517
- } catch (switchError) {
518
- // Silently handle expected errors (invalid sessions, timeouts, network issues)
519
- if ((0, _errorHandlers.isInvalidSessionError)(switchError)) {
520
- await storage.removeItem(storageKeys.activeSessionId);
521
- updateSessionsRef.current(validSessions.filter(session => session.sessionId !== storedActiveSessionId), {
522
- merge: false
755
+ const fullUser = await oxyServices.getCurrentUser();
756
+ await commitWebSession({
757
+ ...callbackSession,
758
+ user: fullUser
523
759
  });
524
- // Don't log expected session errors during restoration
525
- } else if ((0, _errorHandlers.isTimeoutOrNetworkError)(switchError)) {
526
- // Timeout/network error - non-critical, don't block
527
- if (__DEV__) {
528
- _core.logger.debug('Active session validation timeout (expected when offline)', {
529
- component: 'OxyContext',
530
- method: 'restoreSessionsFromStorage'
531
- }, switchError);
760
+ return {
761
+ kind: 'session',
762
+ session: true
763
+ };
764
+ }
765
+ }, {
766
+ // 1) Central SSO return: we are landing back from an `auth.oxy.so/sso`
767
+ // bounce with the result in the URL fragment. Parse it, validate the
768
+ // CSRF state, exchange the opaque code, and commit. On any non-ok
769
+ // outcome `runSsoReturn` sets the per-origin NO_SESSION flag so the
770
+ // terminal `sso-bounce` step is disabled — the loop breaker.
771
+ id: 'sso-return',
772
+ enabled: () => (0, _useWebSSO.isWebBrowser)(),
773
+ run: async () => {
774
+ const committed = await runSsoReturn();
775
+ return committed ? {
776
+ kind: 'session',
777
+ session: true
778
+ } : {
779
+ kind: 'skip'
780
+ };
781
+ }
782
+ }, {
783
+ // 2) FedCM silent reauthn (Chrome) against the CENTRAL IdP
784
+ // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
785
+ // internally; we commit the returned session via
786
+ // `handleWebSSOSession`. Guarded so it fires at most once per page
787
+ // load across remounts. This is an enhancement layered above the
788
+ // opaque-code bounce: when it succeeds the bounce never fires.
789
+ id: 'fedcm-silent',
790
+ enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
791
+ run: async () => {
792
+ servicesSilentAttempted.add(silentKey);
793
+ const session = await oxyServices.silentSignInWithFedCM?.();
794
+ if (!session || !commitWebSession) {
795
+ return {
796
+ kind: 'skip'
797
+ };
798
+ }
799
+ await commitWebSession(session);
800
+ return {
801
+ kind: 'session',
802
+ session: true
803
+ };
804
+ }
805
+ }, {
806
+ // 3) Refresh-cookie restore (first-party only). On `*.oxy.so` the
807
+ // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
808
+ // device-local slot. On a cross-domain RP (mention.earth, …) the
809
+ // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
810
+ // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
811
+ // is correct; cross-domain restore is handled by the SSO bounce.
812
+ id: 'cookie-restore',
813
+ enabled: () => (0, _useWebSSO.isWebBrowser)(),
814
+ run: async () => {
815
+ const restored = await restoreViaRefreshCookie();
816
+ return restored ? {
817
+ kind: 'session',
818
+ session: true
819
+ } : {
820
+ kind: 'skip'
821
+ };
822
+ }
823
+ }, {
824
+ // 4) Stored-session bearer restore. NO `enabled` gate — runs on ALL
825
+ // platforms. This is native's ONLY restore path (every web-only step
826
+ // is disabled off-browser, so native reaches exactly this).
827
+ id: 'stored-session',
828
+ run: async () => {
829
+ const restored = await restoreStoredSession();
830
+ return restored ? {
831
+ kind: 'session',
832
+ session: true
833
+ } : {
834
+ kind: 'skip'
835
+ };
836
+ }
837
+ }, {
838
+ // 5) SSO bounce (TERMINAL, web only, at most once). No local session
839
+ // was found by any step above. Top-level navigate to the central
840
+ // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
841
+ // (returning an opaque code we exchange on the callback) or report
842
+ // `none`. This step tears the document down on success — its `skip`
843
+ // result is only observed if `assign` no-ops. Disabled on the IdP
844
+ // itself, once the NO_SESSION flag is set, or while a bounce guard is
845
+ // still active (loop + self-heal protection).
846
+ id: 'sso-bounce',
847
+ enabled: () => {
848
+ if (!(0, _useWebSSO.isWebBrowser)() || window.top !== window.self) {
849
+ return false;
850
+ }
851
+ const origin = window.location.origin;
852
+ if ((0, _ssoBounce.isCentralIdPOrigin)(origin)) {
853
+ return false;
854
+ }
855
+ if (window.sessionStorage.getItem((0, _ssoBounce.ssoNoSessionKey)(origin)) === '1') {
856
+ return false;
532
857
  }
533
- } else {
534
- // Only log unexpected errors
535
- logger('Active session validation error', switchError);
858
+ if ((0, _ssoBounce.guardActive)(window.sessionStorage, origin, Date.now())) {
859
+ return false;
860
+ }
861
+ return true;
862
+ },
863
+ run: async () => {
864
+ const origin = window.location.origin;
865
+ const state = oxyServices.generateSsoState();
866
+ window.sessionStorage.setItem((0, _ssoBounce.ssoStateKey)(origin), state);
867
+ window.sessionStorage.setItem((0, _ssoBounce.ssoGuardKey)(origin), String(Date.now()));
868
+ window.sessionStorage.setItem((0, _ssoBounce.ssoDestKey)(origin), window.location.href);
869
+ const url = new URL('/sso', (0, _core.resolveCentralAuthUrl)(oxyServices.config?.authWebUrl));
870
+ url.searchParams.set('prompt', 'none');
871
+ url.searchParams.set('client_id', origin);
872
+ url.searchParams.set('return_to', origin + _ssoBounce.SSO_CALLBACK_PATH);
873
+ url.searchParams.set('state', state);
874
+
875
+ // TERMINAL: the document is torn down by this navigation. The
876
+ // `skip` below is only reached if `assign` is a no-op (e.g. the
877
+ // navigation is blocked); in that case we fall through
878
+ // unauthenticated, which is correct.
879
+ ssoBounce.ssoNavigate(url.toString());
880
+ return {
881
+ kind: 'skip'
882
+ };
883
+ }
884
+ }],
885
+ onStepError: (id, error) => {
886
+ if (__DEV__) {
887
+ _core.logger.debug(`Cold-boot step "${id}" errored (non-fatal, falling through)`, {
888
+ component: 'OxyContext',
889
+ method: 'restoreSessionsFromStorage'
890
+ }, error);
536
891
  }
537
892
  }
893
+ });
894
+ if (__DEV__ && outcome.kind === 'session') {
895
+ _core.logger.debug(`Cold boot recovered a session via "${outcome.via}"`, {
896
+ component: 'OxyContext',
897
+ method: 'restoreSessionsFromStorage'
898
+ });
538
899
  }
539
900
  } catch (error) {
540
901
  if (__DEV__) {
@@ -547,7 +908,7 @@ const OxyProvider = ({
547
908
  } finally {
548
909
  setTokenReady(true);
549
910
  }
550
- }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds, restoreViaRefreshCookie]);
911
+ }, [oxyServices, storage, restoreViaRefreshCookie, restoreStoredSession, runSsoReturn]);
551
912
  (0, _react.useEffect)(() => {
552
913
  if (!storage || initialized) {
553
914
  return;
@@ -560,6 +921,37 @@ const OxyProvider = ({
560
921
  });
561
922
  }, [restoreSessionsFromStorage, storage, initialized, logger]);
562
923
 
924
+ // bfcache re-evaluation (web only, registered once). When a page is restored
925
+ // from the back/forward cache (`e.persisted`) NO cold boot re-runs — React
926
+ // state is resurrected as-is — yet the page may have been frozen mid-bounce
927
+ // and resurrected ON the SSO callback with a fresh fragment in the URL. Re-run
928
+ // the `sso-return` parse so the opaque code is still exchanged (and the
929
+ // fragment stripped + NO_SESSION flag maintained) on a bfcache restore. Routed
930
+ // through a ref so the listener registers exactly once and never churns with
931
+ // `runSsoReturn`'s identity.
932
+ const runSsoReturnRef = (0, _react.useRef)(runSsoReturn);
933
+ runSsoReturnRef.current = runSsoReturn;
934
+ (0, _react.useEffect)(() => {
935
+ if (!(0, _useWebSSO.isWebBrowser)()) {
936
+ return;
937
+ }
938
+ const onPageShow = event => {
939
+ if (!event.persisted) {
940
+ return;
941
+ }
942
+ runSsoReturnRef.current().catch(error => {
943
+ if (__DEV__) {
944
+ _core.logger.debug('bfcache SSO return re-evaluation failed (non-fatal)', {
945
+ component: 'OxyContext',
946
+ method: 'onPageShow'
947
+ }, error);
948
+ }
949
+ });
950
+ };
951
+ window.addEventListener('pageshow', onPageShow);
952
+ return () => window.removeEventListener('pageshow', onPageShow);
953
+ }, []);
954
+
563
955
  // Web SSO: Automatically check for cross-domain session on web platforms
564
956
  // Also used for popup auth - updates all state and persists session
565
957
  const handleWebSSOSession = (0, _react.useCallback)(async session => {
@@ -622,7 +1014,21 @@ const OxyProvider = ({
622
1014
  onAuthStateChange?.(fullUser);
623
1015
  }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
624
1016
 
625
- // Enable web SSO only after local storage check completes and no user found
1017
+ // Expose `handleWebSSOSession` to the cold-boot FedCM/iframe/redirect steps,
1018
+ // which reference it through a ref because they are declared above this
1019
+ // callback. Assigned synchronously on every render so the ref is populated
1020
+ // before the cold-boot effect (gated on `storage`/`initialized`) can fire.
1021
+ handleWebSSOSessionRef.current = handleWebSSOSession;
1022
+
1023
+ // Cross-domain silent SSO is now owned by the `fedcm-silent` / `silent-iframe`
1024
+ // cold-boot steps above (the ordered `runColdBoot` sequence). `useWebSSO`
1025
+ // remains mounted for its module-level run-once guard and its interactive
1026
+ // FedCM helpers, and as a bounded post-boot safety net: it can fire at most
1027
+ // once per page load (its own module guard), and only AFTER cold boot has
1028
+ // finished (`tokenReady`) with no user recovered. We deliberately keep
1029
+ // `shouldTryWebSSO` as `tokenReady && !user && initialized` — it is NOT
1030
+ // loosened; cold boot runs while `tokenReady` is false, so this never races
1031
+ // the cold-boot silent step.
626
1032
  const shouldTryWebSSO = (0, _useWebSSO.isWebBrowser)() && tokenReady && !user && initialized;
627
1033
  (0, _useWebSSO.useWebSSO)({
628
1034
  oxyServices,
@@ -642,8 +1048,16 @@ const OxyProvider = ({
642
1048
  // If session is gone (cleared/logged out), clear local session too
643
1049
  const lastIdPCheckRef = (0, _react.useRef)(0);
644
1050
  const pendingIdPCleanupRef = (0, _react.useRef)(null);
1051
+
1052
+ // Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
1053
+ // the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
1054
+ // the prop is undefined but the instance was constructed with the detected
1055
+ // value, so the check must target the same first-party IdP the cold-boot
1056
+ // iframe used.
1057
+ const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
645
1058
  (0, _react.useEffect)(() => {
646
1059
  if (!(0, _useWebSSO.isWebBrowser)() || !user || !initialized) return;
1060
+ const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
647
1061
  const checkIdPSession = () => {
648
1062
  // Debounce: check at most once per 30 seconds
649
1063
  const now = Date.now();
@@ -656,7 +1070,6 @@ const OxyProvider = ({
656
1070
  // Load hidden iframe to check IdP session via postMessage
657
1071
  const iframe = document.createElement('iframe');
658
1072
  iframe.style.cssText = 'display:none;width:0;height:0;border:0';
659
- const idpOrigin = authWebUrl || 'https://auth.oxy.so';
660
1073
  iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
661
1074
  let cleaned = false;
662
1075
  const cleanup = () => {
@@ -670,8 +1083,15 @@ const OxyProvider = ({
670
1083
  if (event.data?.type !== 'oxy-session-check') return;
671
1084
  cleanup();
672
1085
  if (!event.data.hasSession) {
673
- _bloom.toast.info('Your session has ended. Please sign in again.');
674
- await clearSessionState();
1086
+ // Only a SAME-SITE, first-party IdP answer is authoritative enough to
1087
+ // force a local sign-out. On a cross-site / undetermined IdP the
1088
+ // "no session" answer must never clear local state (a third-party
1089
+ // can't be trusted to end this app's session). Surface the toast in
1090
+ // both cases, but gate the destructive `clearSessionState()`.
1091
+ if (isSameSiteIdP(idpOrigin)) {
1092
+ _bloom.toast.info('Your session has ended. Please sign in again.');
1093
+ await clearSessionState();
1094
+ }
675
1095
  }
676
1096
  };
677
1097
  window.addEventListener('message', handleMessage);
@@ -690,7 +1110,7 @@ const OxyProvider = ({
690
1110
  pendingIdPCleanupRef.current?.();
691
1111
  pendingIdPCleanupRef.current = null;
692
1112
  };
693
- }, [user, initialized, clearSessionState, authWebUrl]);
1113
+ }, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
694
1114
  const activeSession = activeSessionId ? sessions.find(session => session.sessionId === activeSessionId) : undefined;
695
1115
  const currentDeviceId = activeSession?.deviceId ?? null;
696
1116
  const userId = user?.id;