@oxyhq/services 8.1.1 → 8.2.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 (29) hide show
  1. package/lib/commonjs/ui/components/OxyProvider.js +30 -23
  2. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  3. package/lib/commonjs/ui/context/OxyContext.js +334 -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/module/ui/components/OxyProvider.js +31 -24
  8. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  9. package/lib/module/ui/context/OxyContext.js +335 -79
  10. package/lib/module/ui/context/OxyContext.js.map +1 -1
  11. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  12. package/lib/module/ui/hooks/useSessionManagement.js +7 -3
  13. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  14. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/ui/types/navigation.d.ts +0 -3
  18. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  19. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  20. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  21. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  22. package/lib/typescript/module/ui/types/navigation.d.ts +0 -3
  23. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  24. package/package.json +2 -2
  25. package/src/ui/components/OxyProvider.tsx +29 -39
  26. package/src/ui/context/OxyContext.tsx +334 -90
  27. package/src/ui/context/hooks/useAuthOperations.ts +1 -1
  28. package/src/ui/hooks/useSessionManagement.ts +8 -4
  29. package/src/ui/types/navigation.ts +0 -3
@@ -3,6 +3,7 @@
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 { autoDetectAuthWebUrl, runColdBoot } from '@oxyhq/core';
6
7
  import { toast } from '@oxyhq/bloom';
7
8
  import { useAuthStore } from "../stores/authStore.js";
8
9
  import { useShallow } from 'zustand/react/shallow';
@@ -28,6 +29,71 @@ const OxyContext = /*#__PURE__*/createContext(null);
28
29
  // `../utils/activeAuthuser` so the session-management and auth-operations hooks
29
30
  // can share them without re-importing this 1k-line context file.
30
31
 
32
+ /**
33
+ * Module-level run-once guard for the cold-boot silent SSO steps
34
+ * (`fedcm-silent` and `silent-iframe`).
35
+ *
36
+ * Both steps trigger a one-shot browser credential / iframe handshake that must
37
+ * fire AT MOST ONCE per page load — otherwise a provider remount storm (route
38
+ * churn, StrictMode double-invoke, error-boundary recovery) becomes a credential
39
+ * request storm. A per-instance ref resets on every remount, so the guard must
40
+ * live at module scope. Keyed on `origin|baseURL` so two providers pointed at
41
+ * the same API from the same origin share one attempt; never cleared because
42
+ * only a fresh page load can change the IdP session state, and a fresh page load
43
+ * starts a fresh module scope.
44
+ *
45
+ * This is a NEW, dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
46
+ * (which guards the post-boot INTERACTIVE button path) and never a core
47
+ * module-level singleton (that re-evaluates under Metro web bundling and the
48
+ * guard would not hold).
49
+ */
50
+ const servicesSilentAttempted = new Set();
51
+
52
+ /**
53
+ * Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
54
+ */
55
+ function silentColdBootKey(oxyServices) {
56
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
57
+ let baseURL = '';
58
+ try {
59
+ baseURL = oxyServices.getBaseURL?.() ?? '';
60
+ } catch {
61
+ baseURL = '';
62
+ }
63
+ return `${origin}|${baseURL}`;
64
+ }
65
+
66
+ /**
67
+ * Whether `idpOrigin` is a same-site, first-party host of the current page —
68
+ * i.e. it shares the page's registrable apex (last two labels), so a "no
69
+ * session" answer from its `/auth/session-check` iframe is authoritative for
70
+ * THIS app and may force a local sign-out.
71
+ *
72
+ * On a cross-site IdP (or any host whose relationship to the page can't be
73
+ * positively established) this returns `false`, so the visibility-driven check
74
+ * may surface a session-ended toast but MUST NOT clear local state — a
75
+ * third-party / undetermined IdP answer can never force logout. Returns `false`
76
+ * off-browser.
77
+ */
78
+ function isSameSiteIdP(idpOrigin) {
79
+ if (typeof window === 'undefined') return false;
80
+ let idpHostname;
81
+ try {
82
+ idpHostname = new URL(idpOrigin).hostname;
83
+ } catch {
84
+ return false;
85
+ }
86
+ const pageHostname = window.location.hostname;
87
+ if (!idpHostname || !pageHostname) return false;
88
+ if (idpHostname === pageHostname) return true;
89
+ const apexOf = hostname => hostname.split('.').slice(-2).join('.');
90
+ const pageApex = apexOf(pageHostname);
91
+ // Require a real registrable apex (≥2 labels) AND an exact apex match AND that
92
+ // the IdP host is the page apex itself or a subdomain of it.
93
+ if (pageHostname.split('.').length < 2) return false;
94
+ if (apexOf(idpHostname) !== pageApex) return false;
95
+ return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
96
+ }
31
97
  let cachedUseFollowHook = null;
32
98
  const loadUseFollowHook = () => {
33
99
  if (cachedUseFollowHook) {
@@ -69,9 +135,19 @@ export const OxyProvider = ({
69
135
  if (providedOxyServices) {
70
136
  oxyServicesRef.current = providedOxyServices;
71
137
  } else if (baseURL) {
138
+ // Auto-detect the FAPI (IdP) origin from the current browser hostname so
139
+ // a consuming RP (mention.earth, homiio.com, alia.onl, …) targets
140
+ // `auth.<rp-apex>` for FedCM + the silent iframe WITHOUT passing
141
+ // `authWebUrl` explicitly — that is what makes both the FedCM config and
142
+ // the `/auth/silent` iframe first-party with the RP (Safari ITP /
143
+ // Firefox TCP need first-party). An explicit `authWebUrl` prop still
144
+ // wins. On native `autoDetectAuthWebUrl()` returns `undefined`
145
+ // (off-browser), leaving the value unchanged. We only auto-detect on the
146
+ // baseURL-only path — a consumer-provided `OxyServices` instance is
147
+ // never mutated.
72
148
  oxyServicesRef.current = new OxyServices({
73
149
  baseURL,
74
- authWebUrl,
150
+ authWebUrl: authWebUrl ?? autoDetectAuthWebUrl(),
75
151
  authRedirectUri
76
152
  });
77
153
  } else {
@@ -136,7 +212,9 @@ export const OxyProvider = ({
136
212
  }, [oxyServices]);
137
213
  const logger = useCallback((message, err) => {
138
214
  if (__DEV__) {
139
- console.warn(`[OxyContext] ${message}`, err);
215
+ loggerUtil.warn(message, {
216
+ component: 'OxyContext'
217
+ }, err);
140
218
  }
141
219
  }, []);
142
220
  const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
@@ -355,6 +433,14 @@ export const OxyProvider = ({
355
433
  const onAuthStateChangeRef = useRef(onAuthStateChange);
356
434
  onAuthStateChangeRef.current = onAuthStateChange;
357
435
 
436
+ // `handleWebSSOSession` is declared further down (it depends on values that
437
+ // are only available there). The FedCM/iframe cold-boot steps need to commit
438
+ // a recovered session through it, so we route the call through a ref that is
439
+ // populated once the callback exists. The ref is assigned synchronously on
440
+ // every render before the cold-boot effect can fire (the effect is gated on
441
+ // `storage` + `initialized`, both of which settle after first render).
442
+ const handleWebSSOSessionRef = useRef(null);
443
+
358
444
  // Cold-boot session restore via the secure refresh cookies (web only).
359
445
  //
360
446
  // Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
@@ -449,90 +535,232 @@ export const OxyProvider = ({
449
535
  onAuthStateChangeRef.current?.(fullUser);
450
536
  return true;
451
537
  }, [oxyServices, persistSessionDurably]);
538
+
539
+ // Native (and offline) stored-session restore — the ONLY restore path that
540
+ // runs on React Native, and the web fallback when no cross-domain step won.
541
+ //
542
+ // Verbatim-extracted from the previous `restoreSessionsFromStorage` body: it
543
+ // reads the durable `session_ids` / `active_session_id` slots, validates each
544
+ // stored session in parallel (bearer `validateSession`), and switches to the
545
+ // stored active session via the session-management `switchSession`. This body
546
+ // is platform-agnostic and gated by NO `enabled()` predicate so it runs on
547
+ // every platform — on native it is reached unconditionally (every web-only
548
+ // step ahead of it is disabled by `isWebBrowser()`), so native restore is
549
+ // exactly this and nothing else (no FedCM / iframe / refresh-all /
550
+ // handleAuthCallback).
551
+ const restoreStoredSession = useCallback(async () => {
552
+ if (!storage) {
553
+ return false;
554
+ }
555
+ const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
556
+ const storedSessionIds = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
557
+ const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
558
+ let validSessions = [];
559
+ if (storedSessionIds.length > 0) {
560
+ // Validate all sessions in parallel (with 8s timeout per session) to avoid
561
+ // sequential blocking that freezes the app on startup
562
+ const VALIDATION_TIMEOUT = 8000;
563
+ const results = await Promise.allSettled(storedSessionIds.map(async sessionId => {
564
+ const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), VALIDATION_TIMEOUT));
565
+ const validationPromise = oxyServices.validateSession(sessionId, {
566
+ useHeaderValidation: true
567
+ }).catch(validationError => {
568
+ if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
569
+ logger('Session validation failed during init', validationError);
570
+ } else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
571
+ loggerUtil.debug('Session validation timeout (expected when offline)', {
572
+ component: 'OxyContext',
573
+ method: 'restoreStoredSession'
574
+ }, validationError);
575
+ }
576
+ return null;
577
+ });
578
+ return Promise.race([validationPromise, timeoutPromise]).then(validation => {
579
+ if (validation?.valid && validation.user) {
580
+ const now = new Date();
581
+ return {
582
+ sessionId,
583
+ deviceId: '',
584
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
585
+ lastActive: now.toISOString(),
586
+ userId: validation.user.id?.toString() ?? '',
587
+ isCurrent: sessionId === storedActiveSessionId
588
+ };
589
+ }
590
+ return null;
591
+ });
592
+ }));
593
+ validSessions = results.filter(r => r.status === 'fulfilled').map(r => r.value).filter(s => s !== null);
594
+
595
+ // Always persist validated sessions to storage (even empty list)
596
+ // to clear stale/expired session IDs that would cause 401 loops on restart
597
+ updateSessionsRef.current(validSessions, {
598
+ merge: false
599
+ });
600
+ }
601
+ if (storedActiveSessionId) {
602
+ try {
603
+ await switchSessionRef.current(storedActiveSessionId);
604
+ return true;
605
+ } catch (switchError) {
606
+ // Silently handle expected errors (invalid sessions, timeouts, network issues)
607
+ if (isInvalidSessionError(switchError)) {
608
+ await storage.removeItem(storageKeys.activeSessionId);
609
+ updateSessionsRef.current(validSessions.filter(session => session.sessionId !== storedActiveSessionId), {
610
+ merge: false
611
+ });
612
+ // Don't log expected session errors during restoration
613
+ } else if (isTimeoutOrNetworkError(switchError)) {
614
+ // Timeout/network error - non-critical, don't block
615
+ if (__DEV__) {
616
+ loggerUtil.debug('Active session validation timeout (expected when offline)', {
617
+ component: 'OxyContext',
618
+ method: 'restoreStoredSession'
619
+ }, switchError);
620
+ }
621
+ } else {
622
+ // Only log unexpected errors
623
+ logger('Active session validation error', switchError);
624
+ }
625
+ }
626
+ }
627
+ return false;
628
+ }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds]);
629
+
630
+ // Cold boot — the single, ordered, short-circuit session-recovery sequence,
631
+ // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
632
+ // FIRST step that yields a session wins; every later step is skipped. Each
633
+ // web-only step is gated by `isWebBrowser()`, so on native ONLY
634
+ // `stored-session` runs.
635
+ //
636
+ // Order (web): redirect callback → FedCM silent → silent iframe → refresh
637
+ // cookie → stored session. Order (native): stored session only.
452
638
  const restoreSessionsFromStorage = useCallback(async () => {
453
639
  if (!storage) {
454
640
  return;
455
641
  }
456
642
  setTokenReady(false);
643
+ const commitWebSession = handleWebSSOSessionRef.current;
644
+ const silentKey = silentColdBootKey(oxyServices);
645
+ const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
457
646
  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();
647
+ const outcome = await runColdBoot({
648
+ steps: [{
649
+ // 1) Redirect callback wins: a popup/redirect sign-in just landed
650
+ // back on this page with `access_token`/`session_id` query params.
651
+ // `handleAuthCallback` plants the token but returns a PLACEHOLDER
652
+ // user (empty id), so we hydrate the REAL user via `getCurrentUser`
653
+ // and commit through `handleWebSSOSession` before claiming a
654
+ // session — never expose a placeholder user (R4).
655
+ id: 'redirect',
656
+ enabled: () => isWebBrowser(),
657
+ run: async () => {
658
+ const callbackSession = oxyServices.handleAuthCallback?.();
659
+ if (!callbackSession || !commitWebSession) {
492
660
  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
661
+ kind: 'skip'
499
662
  };
500
663
  }
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
664
+ const fullUser = await oxyServices.getCurrentUser();
665
+ await commitWebSession({
666
+ ...callbackSession,
667
+ user: fullUser
521
668
  });
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);
669
+ return {
670
+ kind: 'session',
671
+ session: true
672
+ };
673
+ }
674
+ }, {
675
+ // 2) FedCM silent reauthn (Chrome). `silentSignInWithFedCM` plants
676
+ // the access token internally; we commit the returned session via
677
+ // `handleWebSSOSession`. Guarded so it fires at most once per page
678
+ // load across remounts.
679
+ id: 'fedcm-silent',
680
+ enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
681
+ run: async () => {
682
+ servicesSilentAttempted.add(silentKey);
683
+ const session = await oxyServices.silentSignInWithFedCM?.();
684
+ if (!session || !commitWebSession) {
685
+ return {
686
+ kind: 'skip'
687
+ };
530
688
  }
531
- } else {
532
- // Only log unexpected errors
533
- logger('Active session validation error', switchError);
689
+ await commitWebSession(session);
690
+ return {
691
+ kind: 'session',
692
+ session: true
693
+ };
694
+ }
695
+ }, {
696
+ // 3) Silent first-party iframe ({authWebUrl}/auth/silent) for
697
+ // browsers without FedCM (Safari / Firefox). After auto-detection
698
+ // `authWebUrl` is `auth.<rp-apex>`, so the iframe + its
699
+ // `fedcm_session` cookie are first-party with the RP. Shares the
700
+ // one-shot guard with the FedCM step.
701
+ id: 'silent-iframe',
702
+ enabled: () => isWebBrowser() && oxyServices.isFedCMSupported?.() !== true && !servicesSilentAttempted.has(silentKey),
703
+ run: async () => {
704
+ servicesSilentAttempted.add(silentKey);
705
+ const session = await oxyServices.silentSignIn?.();
706
+ if (!session || !commitWebSession) {
707
+ return {
708
+ kind: 'skip'
709
+ };
710
+ }
711
+ await commitWebSession(session);
712
+ return {
713
+ kind: 'session',
714
+ session: true
715
+ };
716
+ }
717
+ }, {
718
+ // 4) Refresh-cookie restore (same-site only). On `*.oxy.so` the
719
+ // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
720
+ // device-local slot. On a cross-domain RP (mention.earth, …) the
721
+ // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
722
+ // `refreshAllSessions` returns `{accounts:[]}` and this skips.
723
+ // That is correct; there is deliberately NO `api.<apex>` bridge.
724
+ id: 'cookie-restore',
725
+ enabled: () => isWebBrowser(),
726
+ run: async () => {
727
+ const restored = await restoreViaRefreshCookie();
728
+ return restored ? {
729
+ kind: 'session',
730
+ session: true
731
+ } : {
732
+ kind: 'skip'
733
+ };
734
+ }
735
+ }, {
736
+ // 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
737
+ // platforms. This is native's ONLY restore path (every web-only
738
+ // step above is disabled off-browser).
739
+ id: 'stored-session',
740
+ run: async () => {
741
+ const restored = await restoreStoredSession();
742
+ return restored ? {
743
+ kind: 'session',
744
+ session: true
745
+ } : {
746
+ kind: 'skip'
747
+ };
748
+ }
749
+ }],
750
+ onStepError: (id, error) => {
751
+ if (__DEV__) {
752
+ loggerUtil.debug(`Cold-boot step "${id}" errored (non-fatal, falling through)`, {
753
+ component: 'OxyContext',
754
+ method: 'restoreSessionsFromStorage'
755
+ }, error);
534
756
  }
535
757
  }
758
+ });
759
+ if (__DEV__ && outcome.kind === 'session') {
760
+ loggerUtil.debug(`Cold boot recovered a session via "${outcome.via}"`, {
761
+ component: 'OxyContext',
762
+ method: 'restoreSessionsFromStorage'
763
+ });
536
764
  }
537
765
  } catch (error) {
538
766
  if (__DEV__) {
@@ -545,7 +773,7 @@ export const OxyProvider = ({
545
773
  } finally {
546
774
  setTokenReady(true);
547
775
  }
548
- }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds, restoreViaRefreshCookie]);
776
+ }, [oxyServices, storage, restoreViaRefreshCookie, restoreStoredSession]);
549
777
  useEffect(() => {
550
778
  if (!storage || initialized) {
551
779
  return;
@@ -620,7 +848,21 @@ export const OxyProvider = ({
620
848
  onAuthStateChange?.(fullUser);
621
849
  }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
622
850
 
623
- // Enable web SSO only after local storage check completes and no user found
851
+ // Expose `handleWebSSOSession` to the cold-boot FedCM/iframe/redirect steps,
852
+ // which reference it through a ref because they are declared above this
853
+ // callback. Assigned synchronously on every render so the ref is populated
854
+ // before the cold-boot effect (gated on `storage`/`initialized`) can fire.
855
+ handleWebSSOSessionRef.current = handleWebSSOSession;
856
+
857
+ // Cross-domain silent SSO is now owned by the `fedcm-silent` / `silent-iframe`
858
+ // cold-boot steps above (the ordered `runColdBoot` sequence). `useWebSSO`
859
+ // remains mounted for its module-level run-once guard and its interactive
860
+ // FedCM helpers, and as a bounded post-boot safety net: it can fire at most
861
+ // once per page load (its own module guard), and only AFTER cold boot has
862
+ // finished (`tokenReady`) with no user recovered. We deliberately keep
863
+ // `shouldTryWebSSO` as `tokenReady && !user && initialized` — it is NOT
864
+ // loosened; cold boot runs while `tokenReady` is false, so this never races
865
+ // the cold-boot silent step.
624
866
  const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;
625
867
  useWebSSO({
626
868
  oxyServices,
@@ -640,8 +882,16 @@ export const OxyProvider = ({
640
882
  // If session is gone (cleared/logged out), clear local session too
641
883
  const lastIdPCheckRef = useRef(0);
642
884
  const pendingIdPCleanupRef = useRef(null);
885
+
886
+ // Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
887
+ // the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
888
+ // the prop is undefined but the instance was constructed with the detected
889
+ // value, so the check must target the same first-party IdP the cold-boot
890
+ // iframe used.
891
+ const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
643
892
  useEffect(() => {
644
893
  if (!isWebBrowser() || !user || !initialized) return;
894
+ const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
645
895
  const checkIdPSession = () => {
646
896
  // Debounce: check at most once per 30 seconds
647
897
  const now = Date.now();
@@ -654,7 +904,6 @@ export const OxyProvider = ({
654
904
  // Load hidden iframe to check IdP session via postMessage
655
905
  const iframe = document.createElement('iframe');
656
906
  iframe.style.cssText = 'display:none;width:0;height:0;border:0';
657
- const idpOrigin = authWebUrl || 'https://auth.oxy.so';
658
907
  iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
659
908
  let cleaned = false;
660
909
  const cleanup = () => {
@@ -668,8 +917,15 @@ export const OxyProvider = ({
668
917
  if (event.data?.type !== 'oxy-session-check') return;
669
918
  cleanup();
670
919
  if (!event.data.hasSession) {
671
- toast.info('Your session has ended. Please sign in again.');
672
- await clearSessionState();
920
+ // Only a SAME-SITE, first-party IdP answer is authoritative enough to
921
+ // force a local sign-out. On a cross-site / undetermined IdP the
922
+ // "no session" answer must never clear local state (a third-party
923
+ // can't be trusted to end this app's session). Surface the toast in
924
+ // both cases, but gate the destructive `clearSessionState()`.
925
+ if (isSameSiteIdP(idpOrigin)) {
926
+ toast.info('Your session has ended. Please sign in again.');
927
+ await clearSessionState();
928
+ }
673
929
  }
674
930
  };
675
931
  window.addEventListener('message', handleMessage);
@@ -688,7 +944,7 @@ export const OxyProvider = ({
688
944
  pendingIdPCleanupRef.current?.();
689
945
  pendingIdPCleanupRef.current = null;
690
946
  };
691
- }, [user, initialized, clearSessionState, authWebUrl]);
947
+ }, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
692
948
  const activeSession = activeSessionId ? sessions.find(session => session.sessionId === activeSessionId) : undefined;
693
949
  const currentDeviceId = activeSession?.deviceId ?? null;
694
950
  const userId = user?.id;