@oxyhq/services 8.1.2 → 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.
- package/lib/commonjs/ui/components/OxyProvider.js +10 -3
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +334 -79
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +7 -3
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +10 -3
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +335 -79
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +7 -3
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/ui/components/OxyProvider.tsx +4 -3
- package/src/ui/context/OxyContext.tsx +334 -90
- package/src/ui/context/hooks/useAuthOperations.ts +1 -1
- package/src/ui/hooks/useSessionManagement.ts +8 -4
|
@@ -14,6 +14,7 @@ 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 { autoDetectAuthWebUrl, runColdBoot } from '@oxyhq/core';
|
|
17
18
|
import { toast } from '@oxyhq/bloom';
|
|
18
19
|
import { useAuthStore, type AuthState } from '../stores/authStore';
|
|
19
20
|
import { useShallow } from 'zustand/react/shallow';
|
|
@@ -113,6 +114,72 @@ export interface OxyContextProviderProps {
|
|
|
113
114
|
onError?: (error: ApiError) => void;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Module-level run-once guard for the cold-boot silent SSO steps
|
|
119
|
+
* (`fedcm-silent` and `silent-iframe`).
|
|
120
|
+
*
|
|
121
|
+
* Both steps trigger a one-shot browser credential / iframe handshake that must
|
|
122
|
+
* fire AT MOST ONCE per page load — otherwise a provider remount storm (route
|
|
123
|
+
* churn, StrictMode double-invoke, error-boundary recovery) becomes a credential
|
|
124
|
+
* request storm. A per-instance ref resets on every remount, so the guard must
|
|
125
|
+
* live at module scope. Keyed on `origin|baseURL` so two providers pointed at
|
|
126
|
+
* the same API from the same origin share one attempt; never cleared because
|
|
127
|
+
* only a fresh page load can change the IdP session state, and a fresh page load
|
|
128
|
+
* starts a fresh module scope.
|
|
129
|
+
*
|
|
130
|
+
* This is a NEW, dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
|
|
131
|
+
* (which guards the post-boot INTERACTIVE button path) and never a core
|
|
132
|
+
* module-level singleton (that re-evaluates under Metro web bundling and the
|
|
133
|
+
* guard would not hold).
|
|
134
|
+
*/
|
|
135
|
+
const servicesSilentAttempted = new Set<string>();
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
|
|
139
|
+
*/
|
|
140
|
+
function silentColdBootKey(oxyServices: OxyServices): string {
|
|
141
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
|
|
142
|
+
let baseURL = '';
|
|
143
|
+
try {
|
|
144
|
+
baseURL = oxyServices.getBaseURL?.() ?? '';
|
|
145
|
+
} catch {
|
|
146
|
+
baseURL = '';
|
|
147
|
+
}
|
|
148
|
+
return `${origin}|${baseURL}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether `idpOrigin` is a same-site, first-party host of the current page —
|
|
153
|
+
* i.e. it shares the page's registrable apex (last two labels), so a "no
|
|
154
|
+
* session" answer from its `/auth/session-check` iframe is authoritative for
|
|
155
|
+
* THIS app and may force a local sign-out.
|
|
156
|
+
*
|
|
157
|
+
* On a cross-site IdP (or any host whose relationship to the page can't be
|
|
158
|
+
* positively established) this returns `false`, so the visibility-driven check
|
|
159
|
+
* may surface a session-ended toast but MUST NOT clear local state — a
|
|
160
|
+
* third-party / undetermined IdP answer can never force logout. Returns `false`
|
|
161
|
+
* off-browser.
|
|
162
|
+
*/
|
|
163
|
+
function isSameSiteIdP(idpOrigin: string): boolean {
|
|
164
|
+
if (typeof window === 'undefined') return false;
|
|
165
|
+
let idpHostname: string;
|
|
166
|
+
try {
|
|
167
|
+
idpHostname = new URL(idpOrigin).hostname;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const pageHostname = window.location.hostname;
|
|
172
|
+
if (!idpHostname || !pageHostname) return false;
|
|
173
|
+
if (idpHostname === pageHostname) return true;
|
|
174
|
+
const apexOf = (hostname: string): string => hostname.split('.').slice(-2).join('.');
|
|
175
|
+
const pageApex = apexOf(pageHostname);
|
|
176
|
+
// Require a real registrable apex (≥2 labels) AND an exact apex match AND that
|
|
177
|
+
// the IdP host is the page apex itself or a subdomain of it.
|
|
178
|
+
if (pageHostname.split('.').length < 2) return false;
|
|
179
|
+
if (apexOf(idpHostname) !== pageApex) return false;
|
|
180
|
+
return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
116
183
|
let cachedUseFollowHook: UseFollowHook | null = null;
|
|
117
184
|
|
|
118
185
|
const loadUseFollowHook = (): UseFollowHook => {
|
|
@@ -159,9 +226,19 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
159
226
|
if (providedOxyServices) {
|
|
160
227
|
oxyServicesRef.current = providedOxyServices;
|
|
161
228
|
} else if (baseURL) {
|
|
229
|
+
// Auto-detect the FAPI (IdP) origin from the current browser hostname so
|
|
230
|
+
// a consuming RP (mention.earth, homiio.com, alia.onl, …) targets
|
|
231
|
+
// `auth.<rp-apex>` for FedCM + the silent iframe WITHOUT passing
|
|
232
|
+
// `authWebUrl` explicitly — that is what makes both the FedCM config and
|
|
233
|
+
// the `/auth/silent` iframe first-party with the RP (Safari ITP /
|
|
234
|
+
// Firefox TCP need first-party). An explicit `authWebUrl` prop still
|
|
235
|
+
// wins. On native `autoDetectAuthWebUrl()` returns `undefined`
|
|
236
|
+
// (off-browser), leaving the value unchanged. We only auto-detect on the
|
|
237
|
+
// baseURL-only path — a consumer-provided `OxyServices` instance is
|
|
238
|
+
// never mutated.
|
|
162
239
|
oxyServicesRef.current = new OxyServices({
|
|
163
240
|
baseURL,
|
|
164
|
-
authWebUrl,
|
|
241
|
+
authWebUrl: authWebUrl ?? autoDetectAuthWebUrl(),
|
|
165
242
|
authRedirectUri,
|
|
166
243
|
});
|
|
167
244
|
} else {
|
|
@@ -234,7 +311,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
234
311
|
|
|
235
312
|
const logger = useCallback((message: string, err?: unknown) => {
|
|
236
313
|
if (__DEV__) {
|
|
237
|
-
|
|
314
|
+
loggerUtil.warn(message, { component: 'OxyContext' }, err);
|
|
238
315
|
}
|
|
239
316
|
}, []);
|
|
240
317
|
|
|
@@ -453,6 +530,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
453
530
|
const onAuthStateChangeRef = useRef(onAuthStateChange);
|
|
454
531
|
onAuthStateChangeRef.current = onAuthStateChange;
|
|
455
532
|
|
|
533
|
+
// `handleWebSSOSession` is declared further down (it depends on values that
|
|
534
|
+
// are only available there). The FedCM/iframe cold-boot steps need to commit
|
|
535
|
+
// a recovered session through it, so we route the call through a ref that is
|
|
536
|
+
// populated once the callback exists. The ref is assigned synchronously on
|
|
537
|
+
// every render before the cold-boot effect can fire (the effect is gated on
|
|
538
|
+
// `storage` + `initialized`, both of which settle after first render).
|
|
539
|
+
const handleWebSSOSessionRef = useRef<((session: SessionLoginResponse) => Promise<void>) | null>(null);
|
|
540
|
+
|
|
456
541
|
// Cold-boot session restore via the secure refresh cookies (web only).
|
|
457
542
|
//
|
|
458
543
|
// Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
|
|
@@ -546,98 +631,230 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
546
631
|
return true;
|
|
547
632
|
}, [oxyServices, persistSessionDurably]);
|
|
548
633
|
|
|
549
|
-
|
|
634
|
+
// Native (and offline) stored-session restore — the ONLY restore path that
|
|
635
|
+
// runs on React Native, and the web fallback when no cross-domain step won.
|
|
636
|
+
//
|
|
637
|
+
// Verbatim-extracted from the previous `restoreSessionsFromStorage` body: it
|
|
638
|
+
// reads the durable `session_ids` / `active_session_id` slots, validates each
|
|
639
|
+
// stored session in parallel (bearer `validateSession`), and switches to the
|
|
640
|
+
// stored active session via the session-management `switchSession`. This body
|
|
641
|
+
// is platform-agnostic and gated by NO `enabled()` predicate so it runs on
|
|
642
|
+
// every platform — on native it is reached unconditionally (every web-only
|
|
643
|
+
// step ahead of it is disabled by `isWebBrowser()`), so native restore is
|
|
644
|
+
// exactly this and nothing else (no FedCM / iframe / refresh-all /
|
|
645
|
+
// handleAuthCallback).
|
|
646
|
+
const restoreStoredSession = useCallback(async (): Promise<boolean> => {
|
|
550
647
|
if (!storage) {
|
|
551
|
-
return;
|
|
648
|
+
return false;
|
|
552
649
|
}
|
|
553
650
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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;
|
|
651
|
+
const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
|
|
652
|
+
const storedSessionIds: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
|
|
653
|
+
const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
|
|
654
|
+
|
|
655
|
+
let validSessions: ClientSession[] = [];
|
|
656
|
+
|
|
657
|
+
if (storedSessionIds.length > 0) {
|
|
658
|
+
// Validate all sessions in parallel (with 8s timeout per session) to avoid
|
|
659
|
+
// sequential blocking that freezes the app on startup
|
|
660
|
+
const VALIDATION_TIMEOUT = 8000;
|
|
661
|
+
const results = await Promise.allSettled(
|
|
662
|
+
storedSessionIds.map(async (sessionId) => {
|
|
663
|
+
const timeoutPromise = new Promise<null>((resolve) =>
|
|
664
|
+
setTimeout(() => resolve(null), VALIDATION_TIMEOUT),
|
|
665
|
+
);
|
|
666
|
+
const validationPromise = oxyServices
|
|
667
|
+
.validateSession(sessionId, { useHeaderValidation: true })
|
|
668
|
+
.catch((validationError: unknown) => {
|
|
669
|
+
if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
|
|
670
|
+
logger('Session validation failed during init', validationError);
|
|
671
|
+
} else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
|
|
672
|
+
loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, validationError as unknown);
|
|
603
673
|
}
|
|
604
674
|
return null;
|
|
605
675
|
});
|
|
606
|
-
}),
|
|
607
|
-
);
|
|
608
676
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
677
|
+
return Promise.race([validationPromise, timeoutPromise]).then((validation) => {
|
|
678
|
+
if (validation?.valid && validation.user) {
|
|
679
|
+
const now = new Date();
|
|
680
|
+
return {
|
|
681
|
+
sessionId,
|
|
682
|
+
deviceId: '',
|
|
683
|
+
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
684
|
+
lastActive: now.toISOString(),
|
|
685
|
+
userId: validation.user.id?.toString() ?? '',
|
|
686
|
+
isCurrent: sessionId === storedActiveSessionId,
|
|
687
|
+
} as ClientSession;
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
});
|
|
691
|
+
}),
|
|
692
|
+
);
|
|
613
693
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
694
|
+
validSessions = results
|
|
695
|
+
.filter((r): r is PromiseFulfilledResult<ClientSession | null> => r.status === 'fulfilled')
|
|
696
|
+
.map((r) => r.value)
|
|
697
|
+
.filter((s): s is ClientSession => s !== null);
|
|
698
|
+
|
|
699
|
+
// Always persist validated sessions to storage (even empty list)
|
|
700
|
+
// to clear stale/expired session IDs that would cause 401 loops on restart
|
|
701
|
+
updateSessionsRef.current(validSessions, { merge: false });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (storedActiveSessionId) {
|
|
705
|
+
try {
|
|
706
|
+
await switchSessionRef.current(storedActiveSessionId);
|
|
707
|
+
return true;
|
|
708
|
+
} catch (switchError) {
|
|
709
|
+
// Silently handle expected errors (invalid sessions, timeouts, network issues)
|
|
710
|
+
if (isInvalidSessionError(switchError)) {
|
|
711
|
+
await storage.removeItem(storageKeys.activeSessionId);
|
|
712
|
+
updateSessionsRef.current(
|
|
713
|
+
validSessions.filter((session) => session.sessionId !== storedActiveSessionId),
|
|
714
|
+
{ merge: false },
|
|
715
|
+
);
|
|
716
|
+
// Don't log expected session errors during restoration
|
|
717
|
+
} else if (isTimeoutOrNetworkError(switchError)) {
|
|
718
|
+
// Timeout/network error - non-critical, don't block
|
|
719
|
+
if (__DEV__) {
|
|
720
|
+
loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, switchError as unknown);
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
// Only log unexpected errors
|
|
724
|
+
logger('Active session validation error', switchError);
|
|
725
|
+
}
|
|
617
726
|
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return false;
|
|
730
|
+
}, [
|
|
731
|
+
logger,
|
|
732
|
+
oxyServices,
|
|
733
|
+
storage,
|
|
734
|
+
storageKeys.activeSessionId,
|
|
735
|
+
storageKeys.sessionIds,
|
|
736
|
+
]);
|
|
618
737
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
738
|
+
// Cold boot — the single, ordered, short-circuit session-recovery sequence,
|
|
739
|
+
// consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
|
|
740
|
+
// FIRST step that yields a session wins; every later step is skipped. Each
|
|
741
|
+
// web-only step is gated by `isWebBrowser()`, so on native ONLY
|
|
742
|
+
// `stored-session` runs.
|
|
743
|
+
//
|
|
744
|
+
// Order (web): redirect callback → FedCM silent → silent iframe → refresh
|
|
745
|
+
// cookie → stored session. Order (native): stored session only.
|
|
746
|
+
const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
|
|
747
|
+
if (!storage) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
setTokenReady(false);
|
|
752
|
+
|
|
753
|
+
const commitWebSession = handleWebSSOSessionRef.current;
|
|
754
|
+
const silentKey = silentColdBootKey(oxyServices);
|
|
755
|
+
const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
const outcome = await runColdBoot<true>({
|
|
759
|
+
steps: [
|
|
760
|
+
{
|
|
761
|
+
// 1) Redirect callback wins: a popup/redirect sign-in just landed
|
|
762
|
+
// back on this page with `access_token`/`session_id` query params.
|
|
763
|
+
// `handleAuthCallback` plants the token but returns a PLACEHOLDER
|
|
764
|
+
// user (empty id), so we hydrate the REAL user via `getCurrentUser`
|
|
765
|
+
// and commit through `handleWebSSOSession` before claiming a
|
|
766
|
+
// session — never expose a placeholder user (R4).
|
|
767
|
+
id: 'redirect',
|
|
768
|
+
enabled: () => isWebBrowser(),
|
|
769
|
+
run: async () => {
|
|
770
|
+
const callbackSession = oxyServices.handleAuthCallback?.();
|
|
771
|
+
if (!callbackSession || !commitWebSession) {
|
|
772
|
+
return { kind: 'skip' };
|
|
773
|
+
}
|
|
774
|
+
const fullUser = await oxyServices.getCurrentUser();
|
|
775
|
+
await commitWebSession({ ...callbackSession, user: fullUser });
|
|
776
|
+
return { kind: 'session', session: true };
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
{
|
|
780
|
+
// 2) FedCM silent reauthn (Chrome). `silentSignInWithFedCM` plants
|
|
781
|
+
// the access token internally; we commit the returned session via
|
|
782
|
+
// `handleWebSSOSession`. Guarded so it fires at most once per page
|
|
783
|
+
// load across remounts.
|
|
784
|
+
id: 'fedcm-silent',
|
|
785
|
+
enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
|
|
786
|
+
run: async () => {
|
|
787
|
+
servicesSilentAttempted.add(silentKey);
|
|
788
|
+
const session = await oxyServices.silentSignInWithFedCM?.();
|
|
789
|
+
if (!session || !commitWebSession) {
|
|
790
|
+
return { kind: 'skip' };
|
|
791
|
+
}
|
|
792
|
+
await commitWebSession(session);
|
|
793
|
+
return { kind: 'session', session: true };
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
// 3) Silent first-party iframe ({authWebUrl}/auth/silent) for
|
|
798
|
+
// browsers without FedCM (Safari / Firefox). After auto-detection
|
|
799
|
+
// `authWebUrl` is `auth.<rp-apex>`, so the iframe + its
|
|
800
|
+
// `fedcm_session` cookie are first-party with the RP. Shares the
|
|
801
|
+
// one-shot guard with the FedCM step.
|
|
802
|
+
id: 'silent-iframe',
|
|
803
|
+
enabled: () =>
|
|
804
|
+
isWebBrowser() &&
|
|
805
|
+
oxyServices.isFedCMSupported?.() !== true &&
|
|
806
|
+
!servicesSilentAttempted.has(silentKey),
|
|
807
|
+
run: async () => {
|
|
808
|
+
servicesSilentAttempted.add(silentKey);
|
|
809
|
+
const session = await oxyServices.silentSignIn?.();
|
|
810
|
+
if (!session || !commitWebSession) {
|
|
811
|
+
return { kind: 'skip' };
|
|
812
|
+
}
|
|
813
|
+
await commitWebSession(session);
|
|
814
|
+
return { kind: 'session', session: true };
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
// 4) Refresh-cookie restore (same-site only). On `*.oxy.so` the
|
|
819
|
+
// httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
|
|
820
|
+
// device-local slot. On a cross-domain RP (mention.earth, …) the
|
|
821
|
+
// cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
|
|
822
|
+
// `refreshAllSessions` returns `{accounts:[]}` and this skips.
|
|
823
|
+
// That is correct; there is deliberately NO `api.<apex>` bridge.
|
|
824
|
+
id: 'cookie-restore',
|
|
825
|
+
enabled: () => isWebBrowser(),
|
|
826
|
+
run: async () => {
|
|
827
|
+
const restored = await restoreViaRefreshCookie();
|
|
828
|
+
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
// 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
|
|
833
|
+
// platforms. This is native's ONLY restore path (every web-only
|
|
834
|
+
// step above is disabled off-browser).
|
|
835
|
+
id: 'stored-session',
|
|
836
|
+
run: async () => {
|
|
837
|
+
const restored = await restoreStoredSession();
|
|
838
|
+
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
],
|
|
842
|
+
onStepError: (id, error) => {
|
|
843
|
+
if (__DEV__) {
|
|
844
|
+
loggerUtil.debug(
|
|
845
|
+
`Cold-boot step "${id}" errored (non-fatal, falling through)`,
|
|
846
|
+
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
847
|
+
error,
|
|
629
848
|
);
|
|
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);
|
|
639
849
|
}
|
|
640
|
-
}
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (__DEV__ && outcome.kind === 'session') {
|
|
854
|
+
loggerUtil.debug(
|
|
855
|
+
`Cold boot recovered a session via "${outcome.via}"`,
|
|
856
|
+
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
857
|
+
);
|
|
641
858
|
}
|
|
642
859
|
} catch (error) {
|
|
643
860
|
if (__DEV__) {
|
|
@@ -648,12 +865,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
648
865
|
setTokenReady(true);
|
|
649
866
|
}
|
|
650
867
|
}, [
|
|
651
|
-
logger,
|
|
652
868
|
oxyServices,
|
|
653
869
|
storage,
|
|
654
|
-
storageKeys.activeSessionId,
|
|
655
|
-
storageKeys.sessionIds,
|
|
656
870
|
restoreViaRefreshCookie,
|
|
871
|
+
restoreStoredSession,
|
|
657
872
|
]);
|
|
658
873
|
|
|
659
874
|
useEffect(() => {
|
|
@@ -729,7 +944,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
729
944
|
onAuthStateChange?.(fullUser);
|
|
730
945
|
}, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
|
|
731
946
|
|
|
732
|
-
//
|
|
947
|
+
// Expose `handleWebSSOSession` to the cold-boot FedCM/iframe/redirect steps,
|
|
948
|
+
// which reference it through a ref because they are declared above this
|
|
949
|
+
// callback. Assigned synchronously on every render so the ref is populated
|
|
950
|
+
// before the cold-boot effect (gated on `storage`/`initialized`) can fire.
|
|
951
|
+
handleWebSSOSessionRef.current = handleWebSSOSession;
|
|
952
|
+
|
|
953
|
+
// Cross-domain silent SSO is now owned by the `fedcm-silent` / `silent-iframe`
|
|
954
|
+
// cold-boot steps above (the ordered `runColdBoot` sequence). `useWebSSO`
|
|
955
|
+
// remains mounted for its module-level run-once guard and its interactive
|
|
956
|
+
// FedCM helpers, and as a bounded post-boot safety net: it can fire at most
|
|
957
|
+
// once per page load (its own module guard), and only AFTER cold boot has
|
|
958
|
+
// finished (`tokenReady`) with no user recovered. We deliberately keep
|
|
959
|
+
// `shouldTryWebSSO` as `tokenReady && !user && initialized` — it is NOT
|
|
960
|
+
// loosened; cold boot runs while `tokenReady` is false, so this never races
|
|
961
|
+
// the cold-boot silent step.
|
|
733
962
|
const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;
|
|
734
963
|
|
|
735
964
|
useWebSSO({
|
|
@@ -749,9 +978,18 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
749
978
|
const lastIdPCheckRef = useRef<number>(0);
|
|
750
979
|
const pendingIdPCleanupRef = useRef<(() => void) | null>(null);
|
|
751
980
|
|
|
981
|
+
// Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
|
|
982
|
+
// the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
|
|
983
|
+
// the prop is undefined but the instance was constructed with the detected
|
|
984
|
+
// value, so the check must target the same first-party IdP the cold-boot
|
|
985
|
+
// iframe used.
|
|
986
|
+
const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
|
|
987
|
+
|
|
752
988
|
useEffect(() => {
|
|
753
989
|
if (!isWebBrowser() || !user || !initialized) return;
|
|
754
990
|
|
|
991
|
+
const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
|
|
992
|
+
|
|
755
993
|
const checkIdPSession = () => {
|
|
756
994
|
// Debounce: check at most once per 30 seconds
|
|
757
995
|
const now = Date.now();
|
|
@@ -764,7 +1002,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
764
1002
|
// Load hidden iframe to check IdP session via postMessage
|
|
765
1003
|
const iframe = document.createElement('iframe');
|
|
766
1004
|
iframe.style.cssText = 'display:none;width:0;height:0;border:0';
|
|
767
|
-
const idpOrigin = authWebUrl || 'https://auth.oxy.so';
|
|
768
1005
|
iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
|
|
769
1006
|
|
|
770
1007
|
let cleaned = false;
|
|
@@ -781,8 +1018,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
781
1018
|
cleanup();
|
|
782
1019
|
|
|
783
1020
|
if (!event.data.hasSession) {
|
|
784
|
-
|
|
785
|
-
|
|
1021
|
+
// Only a SAME-SITE, first-party IdP answer is authoritative enough to
|
|
1022
|
+
// force a local sign-out. On a cross-site / undetermined IdP the
|
|
1023
|
+
// "no session" answer must never clear local state (a third-party
|
|
1024
|
+
// can't be trusted to end this app's session). Surface the toast in
|
|
1025
|
+
// both cases, but gate the destructive `clearSessionState()`.
|
|
1026
|
+
if (isSameSiteIdP(idpOrigin)) {
|
|
1027
|
+
toast.info('Your session has ended. Please sign in again.');
|
|
1028
|
+
await clearSessionState();
|
|
1029
|
+
}
|
|
786
1030
|
}
|
|
787
1031
|
};
|
|
788
1032
|
|
|
@@ -804,7 +1048,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
804
1048
|
pendingIdPCleanupRef.current?.();
|
|
805
1049
|
pendingIdPCleanupRef.current = null;
|
|
806
1050
|
};
|
|
807
|
-
}, [user, initialized, clearSessionState,
|
|
1051
|
+
}, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
|
|
808
1052
|
|
|
809
1053
|
const activeSession = activeSessionId
|
|
810
1054
|
? sessions.find((session) => session.sessionId === activeSessionId)
|
|
@@ -3,7 +3,7 @@ import type { ApiError, User } from '@oxyhq/core';
|
|
|
3
3
|
import type { AuthState } from '../../stores/authStore';
|
|
4
4
|
import type { ClientSession, SessionLoginResponse } from '@oxyhq/core';
|
|
5
5
|
import { DeviceManager } from '@oxyhq/core';
|
|
6
|
-
import { fetchSessionsWithFallback
|
|
6
|
+
import { fetchSessionsWithFallback } from '../../utils/sessionHelpers';
|
|
7
7
|
import { handleAuthError, isInvalidSessionError } from '../../utils/errorHandlers';
|
|
8
8
|
import type { StorageInterface } from '../../utils/storageHelpers';
|
|
9
9
|
import type { OxyServices } from '@oxyhq/core';
|
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import type { ApiError, User } from '@oxyhq/core';
|
|
3
3
|
import type { ClientSession } from '@oxyhq/core';
|
|
4
4
|
import { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual } from '@oxyhq/core';
|
|
5
|
-
import { fetchSessionsWithFallback,
|
|
5
|
+
import { fetchSessionsWithFallback, validateSessionBatch } from '../utils/sessionHelpers';
|
|
6
6
|
import { getStorageKeys, type StorageInterface } from '../utils/storageHelpers';
|
|
7
7
|
import { handleAuthError, isInvalidSessionError } from '../utils/errorHandlers';
|
|
8
8
|
import type { OxyServices } from '@oxyhq/core';
|
|
@@ -364,7 +364,11 @@ export const useSessionManagement = ({
|
|
|
364
364
|
|
|
365
365
|
const refreshSessions = useCallback(
|
|
366
366
|
async (activeUserId?: string): Promise<void> => {
|
|
367
|
-
|
|
367
|
+
// Capture the active session id once so the async closure below uses a
|
|
368
|
+
// narrowed, non-null local instead of re-reading the ref (which the
|
|
369
|
+
// compiler cannot prove stays non-null across awaits).
|
|
370
|
+
const activeSessionId = activeSessionIdRef.current;
|
|
371
|
+
if (!activeSessionId) return;
|
|
368
372
|
|
|
369
373
|
if (refreshInFlightRef.current) {
|
|
370
374
|
await refreshInFlightRef.current;
|
|
@@ -379,7 +383,7 @@ export const useSessionManagement = ({
|
|
|
379
383
|
|
|
380
384
|
const refreshPromise = (async () => {
|
|
381
385
|
try {
|
|
382
|
-
const deviceSessions = await fetchSessionsWithFallback(oxyServices,
|
|
386
|
+
const deviceSessions = await fetchSessionsWithFallback(oxyServices, activeSessionId, {
|
|
383
387
|
fallbackUserId: activeUserId,
|
|
384
388
|
logger,
|
|
385
389
|
});
|
|
@@ -389,7 +393,7 @@ export const useSessionManagement = ({
|
|
|
389
393
|
const otherSessions = sessionsRef.current
|
|
390
394
|
.filter(
|
|
391
395
|
(session) =>
|
|
392
|
-
session.sessionId !==
|
|
396
|
+
session.sessionId !== activeSessionId &&
|
|
393
397
|
!removedSessionsRef.current.has(session.sessionId),
|
|
394
398
|
)
|
|
395
399
|
.map((session) => session.sessionId);
|