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