@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
@@ -14,7 +14,18 @@ import type { User, ApiError, SessionLoginResponse } from '@oxyhq/core';
14
14
  import type { ManagedAccount, CreateManagedAccountInput } from '@oxyhq/core';
15
15
  import { KeyManager } from '@oxyhq/core';
16
16
  import type { ClientSession } from '@oxyhq/core';
17
+ import { runColdBoot, resolveCentralAuthUrl, parseSsoReturnFragment } from '@oxyhq/core';
17
18
  import { toast } from '@oxyhq/bloom';
19
+ import {
20
+ SSO_CALLBACK_PATH,
21
+ ssoStateKey,
22
+ ssoGuardKey,
23
+ ssoDestKey,
24
+ ssoNoSessionKey,
25
+ isCentralIdPOrigin,
26
+ guardActive,
27
+ } from '../utils/ssoBounce';
28
+ import * as ssoBounce from '../utils/ssoBounce';
18
29
  import { useAuthStore, type AuthState } from '../stores/authStore';
19
30
  import { useShallow } from 'zustand/react/shallow';
20
31
  import { useSessionSocket } from '../hooks/useSessionSocket';
@@ -113,6 +124,72 @@ export interface OxyContextProviderProps {
113
124
  onError?: (error: ApiError) => void;
114
125
  }
115
126
 
127
+ /**
128
+ * Module-level run-once guard for the cold-boot `fedcm-silent` step.
129
+ *
130
+ * The FedCM silent step triggers a one-shot `navigator.credentials.get`
131
+ * handshake that must fire AT MOST ONCE per page load — otherwise a provider
132
+ * remount storm (route churn, StrictMode double-invoke, error-boundary
133
+ * recovery) becomes a credential request storm. A per-instance ref resets on
134
+ * every remount, so the guard must live at module scope. Keyed on
135
+ * `origin|baseURL` so two providers pointed at the same API from the same
136
+ * origin share one attempt; never cleared because only a fresh page load can
137
+ * change the central IdP session state, and a fresh page load starts a fresh
138
+ * module scope.
139
+ *
140
+ * This is a dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
141
+ * (which guards the post-boot INTERACTIVE button path) and never a core
142
+ * module-level singleton (that re-evaluates under Metro web bundling and the
143
+ * guard would not hold).
144
+ */
145
+ const servicesSilentAttempted = new Set<string>();
146
+
147
+ /**
148
+ * Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
149
+ */
150
+ function silentColdBootKey(oxyServices: OxyServices): string {
151
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
152
+ let baseURL = '';
153
+ try {
154
+ baseURL = oxyServices.getBaseURL?.() ?? '';
155
+ } catch {
156
+ baseURL = '';
157
+ }
158
+ return `${origin}|${baseURL}`;
159
+ }
160
+
161
+ /**
162
+ * Whether `idpOrigin` is a same-site, first-party host of the current page —
163
+ * i.e. it shares the page's registrable apex (last two labels), so a "no
164
+ * session" answer from its `/auth/session-check` iframe is authoritative for
165
+ * THIS app and may force a local sign-out.
166
+ *
167
+ * On a cross-site IdP (or any host whose relationship to the page can't be
168
+ * positively established) this returns `false`, so the visibility-driven check
169
+ * may surface a session-ended toast but MUST NOT clear local state — a
170
+ * third-party / undetermined IdP answer can never force logout. Returns `false`
171
+ * off-browser.
172
+ */
173
+ function isSameSiteIdP(idpOrigin: string): boolean {
174
+ if (typeof window === 'undefined') return false;
175
+ let idpHostname: string;
176
+ try {
177
+ idpHostname = new URL(idpOrigin).hostname;
178
+ } catch {
179
+ return false;
180
+ }
181
+ const pageHostname = window.location.hostname;
182
+ if (!idpHostname || !pageHostname) return false;
183
+ if (idpHostname === pageHostname) return true;
184
+ const apexOf = (hostname: string): string => hostname.split('.').slice(-2).join('.');
185
+ const pageApex = apexOf(pageHostname);
186
+ // Require a real registrable apex (≥2 labels) AND an exact apex match AND that
187
+ // the IdP host is the page apex itself or a subdomain of it.
188
+ if (pageHostname.split('.').length < 2) return false;
189
+ if (apexOf(idpHostname) !== pageApex) return false;
190
+ return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
191
+ }
192
+
116
193
  let cachedUseFollowHook: UseFollowHook | null = null;
117
194
 
118
195
  const loadUseFollowHook = (): UseFollowHook => {
@@ -159,9 +236,19 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
159
236
  if (providedOxyServices) {
160
237
  oxyServicesRef.current = providedOxyServices;
161
238
  } else if (baseURL) {
239
+ // Target the CENTRAL IdP for TRUE cross-domain SSO. Every RP
240
+ // (mention.earth, homiio.com, alia.onl, …) delegates to the one central
241
+ // `auth.oxy.so` — it owns the host-only `fedcm_session` cookie and the
242
+ // central session store reached via `api.oxy.so`, so a single sign-in
243
+ // there is observed by all RPs through the opaque-code `/sso` bounce.
244
+ // `resolveCentralAuthUrl(authWebUrl)` returns the explicit `authWebUrl`
245
+ // prop when provided (explicit always wins) and the central default
246
+ // otherwise. This is NOT per-apex auto-detection — central SSO is
247
+ // deliberately central. A consumer-provided `OxyServices` instance is
248
+ // never mutated; only the baseURL-only construction path applies this.
162
249
  oxyServicesRef.current = new OxyServices({
163
250
  baseURL,
164
- authWebUrl,
251
+ authWebUrl: resolveCentralAuthUrl(authWebUrl),
165
252
  authRedirectUri,
166
253
  });
167
254
  } else {
@@ -234,7 +321,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
234
321
 
235
322
  const logger = useCallback((message: string, err?: unknown) => {
236
323
  if (__DEV__) {
237
- console.warn(`[OxyContext] ${message}`, err);
324
+ loggerUtil.warn(message, { component: 'OxyContext' }, err);
238
325
  }
239
326
  }, []);
240
327
 
@@ -453,6 +540,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
453
540
  const onAuthStateChangeRef = useRef(onAuthStateChange);
454
541
  onAuthStateChangeRef.current = onAuthStateChange;
455
542
 
543
+ // `handleWebSSOSession` is declared further down (it depends on values that
544
+ // are only available there). The FedCM/iframe cold-boot steps need to commit
545
+ // a recovered session through it, so we route the call through a ref that is
546
+ // populated once the callback exists. The ref is assigned synchronously on
547
+ // every render before the cold-boot effect can fire (the effect is gated on
548
+ // `storage` + `initialized`, both of which settle after first render).
549
+ const handleWebSSOSessionRef = useRef<((session: SessionLoginResponse) => Promise<void>) | null>(null);
550
+
456
551
  // Cold-boot session restore via the secure refresh cookies (web only).
457
552
  //
458
553
  // Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
@@ -546,99 +641,369 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
546
641
  return true;
547
642
  }, [oxyServices, persistSessionDurably]);
548
643
 
549
- const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
644
+ // Native (and offline) stored-session restore — the ONLY restore path that
645
+ // runs on React Native, and the web fallback when no cross-domain step won.
646
+ //
647
+ // Verbatim-extracted from the previous `restoreSessionsFromStorage` body: it
648
+ // reads the durable `session_ids` / `active_session_id` slots, validates each
649
+ // stored session in parallel (bearer `validateSession`), and switches to the
650
+ // stored active session via the session-management `switchSession`. This body
651
+ // is platform-agnostic and gated by NO `enabled()` predicate so it runs on
652
+ // every platform — on native it is reached unconditionally (every web-only
653
+ // step ahead of it is disabled by `isWebBrowser()`), so native restore is
654
+ // exactly this and nothing else (no FedCM / iframe / refresh-all /
655
+ // handleAuthCallback).
656
+ const restoreStoredSession = useCallback(async (): Promise<boolean> => {
550
657
  if (!storage) {
551
- return;
658
+ return false;
552
659
  }
553
660
 
554
- setTokenReady(false);
661
+ const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
662
+ const storedSessionIds: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
663
+ const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
664
+
665
+ let validSessions: ClientSession[] = [];
666
+
667
+ if (storedSessionIds.length > 0) {
668
+ // Validate all sessions in parallel (with 8s timeout per session) to avoid
669
+ // sequential blocking that freezes the app on startup
670
+ const VALIDATION_TIMEOUT = 8000;
671
+ const results = await Promise.allSettled(
672
+ storedSessionIds.map(async (sessionId) => {
673
+ const timeoutPromise = new Promise<null>((resolve) =>
674
+ setTimeout(() => resolve(null), VALIDATION_TIMEOUT),
675
+ );
676
+ const validationPromise = oxyServices
677
+ .validateSession(sessionId, { useHeaderValidation: true })
678
+ .catch((validationError: unknown) => {
679
+ if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
680
+ logger('Session validation failed during init', validationError);
681
+ } else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
682
+ loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, validationError as unknown);
683
+ }
684
+ return null;
685
+ });
555
686
 
556
- try {
557
- // Web cold-boot fast path: restore the active session from the secure
558
- // httpOnly refresh cookie before the bearer-protected stored-session
559
- // validation (which 401s on a hard reload). On success we are signed in
560
- // from the cookie alone — no FedCM needed. On failure we fall through to
561
- // the existing stored-session flow below; nothing is cleared.
562
- if (await restoreViaRefreshCookie()) {
563
- return;
687
+ return Promise.race([validationPromise, timeoutPromise]).then((validation) => {
688
+ if (validation?.valid && validation.user) {
689
+ const now = new Date();
690
+ return {
691
+ sessionId,
692
+ deviceId: '',
693
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
694
+ lastActive: now.toISOString(),
695
+ userId: validation.user.id?.toString() ?? '',
696
+ isCurrent: sessionId === storedActiveSessionId,
697
+ } as ClientSession;
698
+ }
699
+ return null;
700
+ });
701
+ }),
702
+ );
703
+
704
+ validSessions = results
705
+ .filter((r): r is PromiseFulfilledResult<ClientSession | null> => r.status === 'fulfilled')
706
+ .map((r) => r.value)
707
+ .filter((s): s is ClientSession => s !== null);
708
+
709
+ // Always persist validated sessions to storage (even empty list)
710
+ // to clear stale/expired session IDs that would cause 401 loops on restart
711
+ updateSessionsRef.current(validSessions, { merge: false });
712
+ }
713
+
714
+ if (storedActiveSessionId) {
715
+ try {
716
+ await switchSessionRef.current(storedActiveSessionId);
717
+ return true;
718
+ } catch (switchError) {
719
+ // Silently handle expected errors (invalid sessions, timeouts, network issues)
720
+ if (isInvalidSessionError(switchError)) {
721
+ await storage.removeItem(storageKeys.activeSessionId);
722
+ updateSessionsRef.current(
723
+ validSessions.filter((session) => session.sessionId !== storedActiveSessionId),
724
+ { merge: false },
725
+ );
726
+ // Don't log expected session errors during restoration
727
+ } else if (isTimeoutOrNetworkError(switchError)) {
728
+ // Timeout/network error - non-critical, don't block
729
+ if (__DEV__) {
730
+ loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, switchError as unknown);
731
+ }
732
+ } else {
733
+ // Only log unexpected errors
734
+ logger('Active session validation error', switchError);
735
+ }
564
736
  }
737
+ }
565
738
 
566
- const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
567
- const storedSessionIds: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
568
- const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
739
+ return false;
740
+ }, [
741
+ logger,
742
+ oxyServices,
743
+ storage,
744
+ storageKeys.activeSessionId,
745
+ storageKeys.sessionIds,
746
+ ]);
569
747
 
570
- let validSessions: ClientSession[] = [];
748
+ // Central cross-domain SSO return handler (web). Parses the IdP redirect
749
+ // fragment, validates the CSRF `state`, exchanges the opaque single-use code
750
+ // for the real session, commits it, and restores the user's pre-bounce
751
+ // destination. Shared by the `sso-return` cold-boot step AND the bfcache
752
+ // `pageshow` re-evaluation, so the same security-critical logic runs exactly
753
+ // once per delivered fragment regardless of how the page was (re)shown.
754
+ //
755
+ // Returns `true` when a session was committed (caller short-circuits), `false`
756
+ // otherwise. On ANY non-ok outcome — `none`/`error`, state mismatch, missing
757
+ // code, or a failed/forged exchange — it sets the per-origin NO_SESSION flag
758
+ // so `sso-bounce` is disabled and the page cannot loop. Off-browser it is a
759
+ // no-op returning `false` (native never reaches it).
760
+ const runSsoReturn = useCallback(async (): Promise<boolean> => {
761
+ if (!isWebBrowser()) {
762
+ return false;
763
+ }
571
764
 
572
- if (storedSessionIds.length > 0) {
573
- // Validate all sessions in parallel (with 8s timeout per session) to avoid
574
- // sequential blocking that freezes the app on startup
575
- const VALIDATION_TIMEOUT = 8000;
576
- const results = await Promise.allSettled(
577
- storedSessionIds.map(async (sessionId) => {
578
- const timeoutPromise = new Promise<null>((resolve) =>
579
- setTimeout(() => resolve(null), VALIDATION_TIMEOUT),
580
- );
581
- const validationPromise = oxyServices
582
- .validateSession(sessionId, { useHeaderValidation: true })
583
- .catch((validationError: unknown) => {
584
- if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
585
- logger('Session validation failed during init', validationError);
586
- } else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
587
- loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreSessionsFromStorage' }, validationError as unknown);
588
- }
589
- return null;
590
- });
591
-
592
- return Promise.race([validationPromise, timeoutPromise]).then((validation) => {
593
- if (validation?.valid && validation.user) {
594
- const now = new Date();
595
- return {
596
- sessionId,
597
- deviceId: '',
598
- expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
599
- lastActive: now.toISOString(),
600
- userId: validation.user.id?.toString() ?? '',
601
- isCurrent: sessionId === storedActiveSessionId,
602
- } as ClientSession;
603
- }
604
- return null;
605
- });
606
- }),
607
- );
765
+ const ret = parseSsoReturnFragment(window.location.hash);
766
+ if (!ret) {
767
+ // Not an oxy_sso fragment nothing to do (do NOT touch any flags).
768
+ return false;
769
+ }
770
+
771
+ const origin = window.location.origin;
772
+ const expectedState = window.sessionStorage.getItem(ssoStateKey(origin));
773
+ const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
608
774
 
609
- validSessions = results
610
- .filter((r): r is PromiseFulfilledResult<ClientSession | null> => r.status === 'fulfilled')
611
- .map((r) => r.value)
612
- .filter((s): s is ClientSession => s !== null);
775
+ // Strip the fragment FIRST so the opaque code never lingers in the address
776
+ // bar, history, or a copy-paste even if a later step throws.
777
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
778
+ window.sessionStorage.removeItem(ssoStateKey(origin));
779
+
780
+ const markNoSession = () => {
781
+ window.sessionStorage.setItem(ssoNoSessionKey(origin), '1');
782
+ };
783
+
784
+ if (ret.kind === 'none' || ret.kind === 'error') {
785
+ // The central IdP had no session (or the bounce failed). Record it so we
786
+ // do not bounce again this tab — the definitive loop breaker.
787
+ markNoSession();
788
+ return false;
789
+ }
613
790
 
614
- // Always persist validated sessions to storage (even empty list)
615
- // to clear stale/expired session IDs that would cause 401 loops on restart
616
- updateSessionsRef.current(validSessions, { merge: false });
791
+ if (!stateOk || !ret.code) {
792
+ // Forged / replayed / stale fragment, or a malformed ok with no code.
793
+ // Treat exactly like "no session": never exchange, never loop.
794
+ markNoSession();
795
+ return false;
796
+ }
797
+
798
+ const commitWebSession = handleWebSSOSessionRef.current;
799
+ let session: SessionLoginResponse;
800
+ try {
801
+ session = await oxyServices.exchangeSsoCode(ret.code);
802
+ } catch (error) {
803
+ if (__DEV__) {
804
+ loggerUtil.debug(
805
+ 'SSO code exchange failed (treating as no session)',
806
+ { component: 'OxyContext', method: 'runSsoReturn' },
807
+ error,
808
+ );
617
809
  }
810
+ markNoSession();
811
+ return false;
812
+ }
813
+
814
+ if (!session?.sessionId || !commitWebSession) {
815
+ markNoSession();
816
+ return false;
817
+ }
618
818
 
619
- if (storedActiveSessionId) {
819
+ await commitWebSession(session);
820
+
821
+ // Restore the user's real destination captured before the bounce. We only
822
+ // rewrite the URL when we are sitting on the callback path — otherwise the
823
+ // current URL is already the destination.
824
+ if (window.location.pathname === SSO_CALLBACK_PATH) {
825
+ const dest = window.sessionStorage.getItem(ssoDestKey(origin));
826
+ if (dest) {
620
827
  try {
621
- await switchSessionRef.current(storedActiveSessionId);
622
- } catch (switchError) {
623
- // Silently handle expected errors (invalid sessions, timeouts, network issues)
624
- if (isInvalidSessionError(switchError)) {
625
- await storage.removeItem(storageKeys.activeSessionId);
626
- updateSessionsRef.current(
627
- validSessions.filter((session) => session.sessionId !== storedActiveSessionId),
628
- { merge: false },
629
- );
630
- // Don't log expected session errors during restoration
631
- } else if (isTimeoutOrNetworkError(switchError)) {
632
- // Timeout/network error - non-critical, don't block
633
- if (__DEV__) {
634
- loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreSessionsFromStorage' }, switchError as unknown);
635
- }
636
- } else {
637
- // Only log unexpected errors
638
- logger('Active session validation error', switchError);
828
+ const destUrl = new URL(dest);
829
+ // Same-origin only — never honour a cross-origin destination that
830
+ // could have been planted to redirect the freshly signed-in user.
831
+ if (destUrl.origin === origin) {
832
+ window.history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
639
833
  }
834
+ } catch {
835
+ // Malformed stored destination — leave the URL on the callback path.
640
836
  }
641
837
  }
838
+ }
839
+ window.sessionStorage.removeItem(ssoDestKey(origin));
840
+
841
+ return true;
842
+ }, [oxyServices]);
843
+
844
+ // Cold boot — the single, ordered, short-circuit session-recovery sequence,
845
+ // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
846
+ // FIRST step that yields a session wins; every later step is skipped. Each
847
+ // web-only step is gated by `isWebBrowser()`, so on native ONLY
848
+ // `stored-session` runs.
849
+ //
850
+ // Order (web): redirect callback → SSO return → FedCM silent → cookie restore
851
+ // → stored session → SSO bounce (terminal). Order (native): stored session
852
+ // only (every web-only step is disabled off-browser).
853
+ const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
854
+ if (!storage) {
855
+ return;
856
+ }
857
+
858
+ setTokenReady(false);
859
+
860
+ const commitWebSession = handleWebSSOSessionRef.current;
861
+ const silentKey = silentColdBootKey(oxyServices);
862
+ const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
863
+
864
+ try {
865
+ const outcome = await runColdBoot<true>({
866
+ steps: [
867
+ {
868
+ // 0) Redirect callback wins: a popup/redirect sign-in just landed
869
+ // back on this page with `access_token`/`session_id` query params.
870
+ // `handleAuthCallback` plants the token but returns a PLACEHOLDER
871
+ // user (empty id), so we hydrate the REAL user via `getCurrentUser`
872
+ // and commit through `handleWebSSOSession` before claiming a
873
+ // session — never expose a placeholder user (R4).
874
+ id: 'redirect',
875
+ enabled: () => isWebBrowser(),
876
+ run: async () => {
877
+ const callbackSession = oxyServices.handleAuthCallback?.();
878
+ if (!callbackSession || !commitWebSession) {
879
+ return { kind: 'skip' };
880
+ }
881
+ const fullUser = await oxyServices.getCurrentUser();
882
+ await commitWebSession({ ...callbackSession, user: fullUser });
883
+ return { kind: 'session', session: true };
884
+ },
885
+ },
886
+ {
887
+ // 1) Central SSO return: we are landing back from an `auth.oxy.so/sso`
888
+ // bounce with the result in the URL fragment. Parse it, validate the
889
+ // CSRF state, exchange the opaque code, and commit. On any non-ok
890
+ // outcome `runSsoReturn` sets the per-origin NO_SESSION flag so the
891
+ // terminal `sso-bounce` step is disabled — the loop breaker.
892
+ id: 'sso-return',
893
+ enabled: () => isWebBrowser(),
894
+ run: async () => {
895
+ const committed = await runSsoReturn();
896
+ return committed ? { kind: 'session', session: true } : { kind: 'skip' };
897
+ },
898
+ },
899
+ {
900
+ // 2) FedCM silent reauthn (Chrome) against the CENTRAL IdP
901
+ // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
902
+ // internally; we commit the returned session via
903
+ // `handleWebSSOSession`. Guarded so it fires at most once per page
904
+ // load across remounts. This is an enhancement layered above the
905
+ // opaque-code bounce: when it succeeds the bounce never fires.
906
+ id: 'fedcm-silent',
907
+ enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
908
+ run: async () => {
909
+ servicesSilentAttempted.add(silentKey);
910
+ const session = await oxyServices.silentSignInWithFedCM?.();
911
+ if (!session || !commitWebSession) {
912
+ return { kind: 'skip' };
913
+ }
914
+ await commitWebSession(session);
915
+ return { kind: 'session', session: true };
916
+ },
917
+ },
918
+ {
919
+ // 3) Refresh-cookie restore (first-party only). On `*.oxy.so` the
920
+ // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
921
+ // device-local slot. On a cross-domain RP (mention.earth, …) the
922
+ // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
923
+ // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
924
+ // is correct; cross-domain restore is handled by the SSO bounce.
925
+ id: 'cookie-restore',
926
+ enabled: () => isWebBrowser(),
927
+ run: async () => {
928
+ const restored = await restoreViaRefreshCookie();
929
+ return restored ? { kind: 'session', session: true } : { kind: 'skip' };
930
+ },
931
+ },
932
+ {
933
+ // 4) Stored-session bearer restore. NO `enabled` gate — runs on ALL
934
+ // platforms. This is native's ONLY restore path (every web-only step
935
+ // is disabled off-browser, so native reaches exactly this).
936
+ id: 'stored-session',
937
+ run: async () => {
938
+ const restored = await restoreStoredSession();
939
+ return restored ? { kind: 'session', session: true } : { kind: 'skip' };
940
+ },
941
+ },
942
+ {
943
+ // 5) SSO bounce (TERMINAL, web only, at most once). No local session
944
+ // was found by any step above. Top-level navigate to the central
945
+ // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
946
+ // (returning an opaque code we exchange on the callback) or report
947
+ // `none`. This step tears the document down on success — its `skip`
948
+ // result is only observed if `assign` no-ops. Disabled on the IdP
949
+ // itself, once the NO_SESSION flag is set, or while a bounce guard is
950
+ // still active (loop + self-heal protection).
951
+ id: 'sso-bounce',
952
+ enabled: () => {
953
+ if (!isWebBrowser() || window.top !== window.self) {
954
+ return false;
955
+ }
956
+ const origin = window.location.origin;
957
+ if (isCentralIdPOrigin(origin)) {
958
+ return false;
959
+ }
960
+ if (window.sessionStorage.getItem(ssoNoSessionKey(origin)) === '1') {
961
+ return false;
962
+ }
963
+ if (guardActive(window.sessionStorage, origin, Date.now())) {
964
+ return false;
965
+ }
966
+ return true;
967
+ },
968
+ run: async () => {
969
+ const origin = window.location.origin;
970
+ const state = oxyServices.generateSsoState();
971
+ window.sessionStorage.setItem(ssoStateKey(origin), state);
972
+ window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
973
+ window.sessionStorage.setItem(ssoDestKey(origin), window.location.href);
974
+
975
+ const url = new URL('/sso', resolveCentralAuthUrl(oxyServices.config?.authWebUrl));
976
+ url.searchParams.set('prompt', 'none');
977
+ url.searchParams.set('client_id', origin);
978
+ url.searchParams.set('return_to', origin + SSO_CALLBACK_PATH);
979
+ url.searchParams.set('state', state);
980
+
981
+ // TERMINAL: the document is torn down by this navigation. The
982
+ // `skip` below is only reached if `assign` is a no-op (e.g. the
983
+ // navigation is blocked); in that case we fall through
984
+ // unauthenticated, which is correct.
985
+ ssoBounce.ssoNavigate(url.toString());
986
+ return { kind: 'skip' };
987
+ },
988
+ },
989
+ ],
990
+ onStepError: (id, error) => {
991
+ if (__DEV__) {
992
+ loggerUtil.debug(
993
+ `Cold-boot step "${id}" errored (non-fatal, falling through)`,
994
+ { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
995
+ error,
996
+ );
997
+ }
998
+ },
999
+ });
1000
+
1001
+ if (__DEV__ && outcome.kind === 'session') {
1002
+ loggerUtil.debug(
1003
+ `Cold boot recovered a session via "${outcome.via}"`,
1004
+ { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
1005
+ );
1006
+ }
642
1007
  } catch (error) {
643
1008
  if (__DEV__) {
644
1009
  loggerUtil.error('Auth init error', error instanceof Error ? error : new Error(String(error)), { component: 'OxyContext', method: 'restoreSessionsFromStorage' });
@@ -648,12 +1013,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
648
1013
  setTokenReady(true);
649
1014
  }
650
1015
  }, [
651
- logger,
652
1016
  oxyServices,
653
1017
  storage,
654
- storageKeys.activeSessionId,
655
- storageKeys.sessionIds,
656
1018
  restoreViaRefreshCookie,
1019
+ restoreStoredSession,
1020
+ runSsoReturn,
657
1021
  ]);
658
1022
 
659
1023
  useEffect(() => {
@@ -669,6 +1033,39 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
669
1033
  });
670
1034
  }, [restoreSessionsFromStorage, storage, initialized, logger]);
671
1035
 
1036
+ // bfcache re-evaluation (web only, registered once). When a page is restored
1037
+ // from the back/forward cache (`e.persisted`) NO cold boot re-runs — React
1038
+ // state is resurrected as-is — yet the page may have been frozen mid-bounce
1039
+ // and resurrected ON the SSO callback with a fresh fragment in the URL. Re-run
1040
+ // the `sso-return` parse so the opaque code is still exchanged (and the
1041
+ // fragment stripped + NO_SESSION flag maintained) on a bfcache restore. Routed
1042
+ // through a ref so the listener registers exactly once and never churns with
1043
+ // `runSsoReturn`'s identity.
1044
+ const runSsoReturnRef = useRef(runSsoReturn);
1045
+ runSsoReturnRef.current = runSsoReturn;
1046
+
1047
+ useEffect(() => {
1048
+ if (!isWebBrowser()) {
1049
+ return;
1050
+ }
1051
+ const onPageShow = (event: PageTransitionEvent) => {
1052
+ if (!event.persisted) {
1053
+ return;
1054
+ }
1055
+ runSsoReturnRef.current().catch((error) => {
1056
+ if (__DEV__) {
1057
+ loggerUtil.debug(
1058
+ 'bfcache SSO return re-evaluation failed (non-fatal)',
1059
+ { component: 'OxyContext', method: 'onPageShow' },
1060
+ error,
1061
+ );
1062
+ }
1063
+ });
1064
+ };
1065
+ window.addEventListener('pageshow', onPageShow);
1066
+ return () => window.removeEventListener('pageshow', onPageShow);
1067
+ }, []);
1068
+
672
1069
  // Web SSO: Automatically check for cross-domain session on web platforms
673
1070
  // Also used for popup auth - updates all state and persists session
674
1071
  const handleWebSSOSession = useCallback(async (session: SessionLoginResponse) => {
@@ -729,7 +1126,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
729
1126
  onAuthStateChange?.(fullUser);
730
1127
  }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
731
1128
 
732
- // Enable web SSO only after local storage check completes and no user found
1129
+ // Expose `handleWebSSOSession` to the cold-boot FedCM/iframe/redirect steps,
1130
+ // which reference it through a ref because they are declared above this
1131
+ // callback. Assigned synchronously on every render so the ref is populated
1132
+ // before the cold-boot effect (gated on `storage`/`initialized`) can fire.
1133
+ handleWebSSOSessionRef.current = handleWebSSOSession;
1134
+
1135
+ // Cross-domain silent SSO is now owned by the `fedcm-silent` / `silent-iframe`
1136
+ // cold-boot steps above (the ordered `runColdBoot` sequence). `useWebSSO`
1137
+ // remains mounted for its module-level run-once guard and its interactive
1138
+ // FedCM helpers, and as a bounded post-boot safety net: it can fire at most
1139
+ // once per page load (its own module guard), and only AFTER cold boot has
1140
+ // finished (`tokenReady`) with no user recovered. We deliberately keep
1141
+ // `shouldTryWebSSO` as `tokenReady && !user && initialized` — it is NOT
1142
+ // loosened; cold boot runs while `tokenReady` is false, so this never races
1143
+ // the cold-boot silent step.
733
1144
  const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;
734
1145
 
735
1146
  useWebSSO({
@@ -749,9 +1160,18 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
749
1160
  const lastIdPCheckRef = useRef<number>(0);
750
1161
  const pendingIdPCleanupRef = useRef<(() => void) | null>(null);
751
1162
 
1163
+ // Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
1164
+ // the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
1165
+ // the prop is undefined but the instance was constructed with the detected
1166
+ // value, so the check must target the same first-party IdP the cold-boot
1167
+ // iframe used.
1168
+ const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
1169
+
752
1170
  useEffect(() => {
753
1171
  if (!isWebBrowser() || !user || !initialized) return;
754
1172
 
1173
+ const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
1174
+
755
1175
  const checkIdPSession = () => {
756
1176
  // Debounce: check at most once per 30 seconds
757
1177
  const now = Date.now();
@@ -764,7 +1184,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
764
1184
  // Load hidden iframe to check IdP session via postMessage
765
1185
  const iframe = document.createElement('iframe');
766
1186
  iframe.style.cssText = 'display:none;width:0;height:0;border:0';
767
- const idpOrigin = authWebUrl || 'https://auth.oxy.so';
768
1187
  iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
769
1188
 
770
1189
  let cleaned = false;
@@ -781,8 +1200,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
781
1200
  cleanup();
782
1201
 
783
1202
  if (!event.data.hasSession) {
784
- toast.info('Your session has ended. Please sign in again.');
785
- await clearSessionState();
1203
+ // Only a SAME-SITE, first-party IdP answer is authoritative enough to
1204
+ // force a local sign-out. On a cross-site / undetermined IdP the
1205
+ // "no session" answer must never clear local state (a third-party
1206
+ // can't be trusted to end this app's session). Surface the toast in
1207
+ // both cases, but gate the destructive `clearSessionState()`.
1208
+ if (isSameSiteIdP(idpOrigin)) {
1209
+ toast.info('Your session has ended. Please sign in again.');
1210
+ await clearSessionState();
1211
+ }
786
1212
  }
787
1213
  };
788
1214
 
@@ -804,7 +1230,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
804
1230
  pendingIdPCleanupRef.current?.();
805
1231
  pendingIdPCleanupRef.current = null;
806
1232
  };
807
- }, [user, initialized, clearSessionState, authWebUrl]);
1233
+ }, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
808
1234
 
809
1235
  const activeSession = activeSessionId
810
1236
  ? sessions.find((session) => session.sessionId === activeSessionId)