@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.
- package/lib/commonjs/ui/components/OxyProvider.js +30 -23
- 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 +31 -24
- 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/commonjs/ui/types/navigation.d.ts +0 -3
- package/lib/typescript/commonjs/ui/types/navigation.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/lib/typescript/module/ui/types/navigation.d.ts +0 -3
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/ui/components/OxyProvider.tsx +29 -39
- 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
- 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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
}, [
|
|
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
|
-
//
|
|
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
|
-
|
|
672
|
-
|
|
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,
|
|
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;
|