@oxyhq/services 14.0.1 → 15.0.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/index.js +0 -17
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/SignInAccountChooser.js +189 -0
- package/lib/commonjs/ui/components/SignInAccountChooser.js.map +1 -0
- package/lib/commonjs/ui/components/SignInModal.js +350 -131
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +340 -1547
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +61 -64
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/hooks/useAuth.js +14 -33
- package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
- package/lib/commonjs/ui/hooks/usePasswordSignIn.js +170 -0
- package/lib/commonjs/ui/hooks/usePasswordSignIn.js.map +1 -0
- package/lib/commonjs/ui/hooks/useProfileEditing.js +2 -5
- package/lib/commonjs/ui/hooks/useProfileEditing.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -2
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/commonjs/ui/navigation/routes.js +1 -0
- package/lib/commonjs/ui/navigation/routes.js.map +1 -1
- package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +93 -68
- package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/EditProfileScreen.js +187 -0
- package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -0
- package/lib/commonjs/ui/screens/ManageAccountScreen.js +3 -7
- package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +231 -95
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/ProfileScreen.js +15 -2
- package/lib/commonjs/ui/screens/ProfileScreen.js.map +1 -1
- package/lib/commonjs/ui/screens/linkFormat.js +38 -0
- package/lib/commonjs/ui/screens/linkFormat.js.map +1 -0
- package/lib/commonjs/ui/session/authStore.js +146 -0
- package/lib/commonjs/ui/session/authStore.js.map +1 -0
- package/lib/commonjs/ui/session/createSessionClient.js +25 -10
- package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
- package/lib/commonjs/ui/session/index.js +13 -7
- package/lib/commonjs/ui/session/index.js.map +1 -1
- package/lib/commonjs/ui/session/tokenTransport.js +19 -31
- package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
- package/lib/commonjs/ui/utils/isWebBrowser.js +13 -0
- package/lib/commonjs/ui/utils/isWebBrowser.js.map +1 -0
- package/lib/commonjs/utils/deviceFlowSignIn.js +13 -16
- package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
- package/lib/module/index.js +0 -5
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/SignInAccountChooser.js +183 -0
- package/lib/module/ui/components/SignInAccountChooser.js.map +1 -0
- package/lib/module/ui/components/SignInModal.js +352 -134
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +342 -1551
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +60 -63
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/hooks/useAuth.js +14 -33
- package/lib/module/ui/hooks/useAuth.js.map +1 -1
- package/lib/module/ui/hooks/usePasswordSignIn.js +165 -0
- package/lib/module/ui/hooks/usePasswordSignIn.js.map +1 -0
- package/lib/module/ui/hooks/useProfileEditing.js +2 -5
- package/lib/module/ui/hooks/useProfileEditing.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/navigation/routes.js +1 -0
- package/lib/module/ui/navigation/routes.js.map +1 -1
- package/lib/module/ui/screens/EditProfileFieldScreen.js +91 -66
- package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
- package/lib/module/ui/screens/EditProfileScreen.js +182 -0
- package/lib/module/ui/screens/EditProfileScreen.js.map +1 -0
- package/lib/module/ui/screens/ManageAccountScreen.js +3 -7
- package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
- package/lib/module/ui/screens/OxyAuthScreen.js +233 -97
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/ui/screens/ProfileScreen.js +15 -2
- package/lib/module/ui/screens/ProfileScreen.js.map +1 -1
- package/lib/module/ui/screens/linkFormat.js +31 -0
- package/lib/module/ui/screens/linkFormat.js.map +1 -0
- package/lib/module/ui/session/authStore.js +143 -0
- package/lib/module/ui/session/authStore.js.map +1 -0
- package/lib/module/ui/session/createSessionClient.js +25 -10
- package/lib/module/ui/session/createSessionClient.js.map +1 -1
- package/lib/module/ui/session/index.js +9 -9
- package/lib/module/ui/session/index.js.map +1 -1
- package/lib/module/ui/session/tokenTransport.js +20 -33
- package/lib/module/ui/session/tokenTransport.js.map +1 -1
- package/lib/module/ui/utils/isWebBrowser.js +11 -0
- package/lib/module/ui/utils/isWebBrowser.js.map +1 -0
- package/lib/module/utils/deviceFlowSignIn.js +14 -17
- package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +0 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/SignInAccountChooser.d.ts +29 -0
- package/lib/typescript/commonjs/ui/components/SignInAccountChooser.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/SignInModal.d.ts +15 -10
- package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +36 -42
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +18 -23
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +0 -2
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/usePasswordSignIn.d.ts +31 -0
- package/lib/typescript/commonjs/ui/hooks/usePasswordSignIn.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts +0 -1
- package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts +14 -0
- package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts +1 -1
- package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +13 -12
- package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/ProfileScreen.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/linkFormat.d.ts +23 -0
- package/lib/typescript/commonjs/ui/screens/linkFormat.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/authStore.d.ts +33 -0
- package/lib/typescript/commonjs/ui/session/authStore.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +20 -9
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/index.d.ts +9 -9
- package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +13 -19
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/isWebBrowser.d.ts +9 -0
- package/lib/typescript/commonjs/ui/utils/isWebBrowser.d.ts.map +1 -0
- package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +15 -13
- package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +0 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/SignInAccountChooser.d.ts +29 -0
- package/lib/typescript/module/ui/components/SignInAccountChooser.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/SignInModal.d.ts +15 -10
- package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +36 -42
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +18 -23
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +0 -2
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/usePasswordSignIn.d.ts +31 -0
- package/lib/typescript/module/ui/hooks/usePasswordSignIn.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts +0 -1
- package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts.map +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
- package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts +14 -0
- package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts +1 -1
- package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +13 -12
- package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/ProfileScreen.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/linkFormat.d.ts +23 -0
- package/lib/typescript/module/ui/screens/linkFormat.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/authStore.d.ts +33 -0
- package/lib/typescript/module/ui/session/authStore.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/createSessionClient.d.ts +20 -9
- package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/index.d.ts +9 -9
- package/lib/typescript/module/ui/session/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/tokenTransport.d.ts +13 -19
- package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/isWebBrowser.d.ts +9 -0
- package/lib/typescript/module/ui/utils/isWebBrowser.d.ts.map +1 -0
- package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +15 -13
- package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +0 -4
- package/src/ui/components/SignInAccountChooser.tsx +162 -0
- package/src/ui/components/SignInModal.tsx +309 -139
- package/src/ui/context/OxyContext.tsx +532 -1785
- package/src/ui/context/hooks/useAuthOperations.ts +65 -76
- package/src/ui/hooks/useAuth.ts +14 -35
- package/src/ui/hooks/usePasswordSignIn.ts +207 -0
- package/src/ui/hooks/useProfileEditing.ts +2 -8
- package/src/ui/hooks/useSessionManagement.ts +1 -1
- package/src/ui/navigation/routes.ts +2 -0
- package/src/ui/screens/EditProfileFieldScreen.tsx +102 -66
- package/src/ui/screens/EditProfileScreen.tsx +155 -0
- package/src/ui/screens/ManageAccountScreen.tsx +3 -7
- package/src/ui/screens/OxyAuthScreen.tsx +259 -112
- package/src/ui/screens/ProfileScreen.tsx +15 -2
- package/src/ui/screens/__tests__/linkFormat.test.ts +73 -0
- package/src/ui/screens/linkFormat.ts +37 -0
- package/src/ui/session/__tests__/createSessionClient.test.ts +49 -6
- package/src/ui/session/__tests__/tokenTransport.test.ts +37 -49
- package/src/ui/session/authStore.ts +164 -0
- package/src/ui/session/createSessionClient.ts +32 -11
- package/src/ui/session/index.ts +10 -9
- package/src/ui/session/tokenTransport.ts +26 -36
- package/src/ui/utils/isWebBrowser.ts +8 -0
- package/src/utils/__tests__/deviceFlowSignIn.test.ts +50 -131
- package/src/utils/deviceFlowSignIn.ts +19 -26
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js +0 -243
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +0 -1
- package/lib/commonjs/ui/context/silentSessionRestore.js +0 -56
- package/lib/commonjs/ui/context/silentSessionRestore.js.map +0 -1
- package/lib/commonjs/ui/hooks/useWebSSO.js +0 -28
- package/lib/commonjs/ui/hooks/useWebSSO.js.map +0 -1
- package/lib/commonjs/ui/session/projectSessionState.js +0 -86
- package/lib/commonjs/ui/session/projectSessionState.js.map +0 -1
- package/lib/commonjs/ui/session/sessionClientHost.js +0 -30
- package/lib/commonjs/ui/session/sessionClientHost.js.map +0 -1
- package/lib/commonjs/ui/utils/activeAuthuser.js +0 -142
- package/lib/commonjs/ui/utils/activeAuthuser.js.map +0 -1
- package/lib/commonjs/utils/crossApex.js +0 -74
- package/lib/commonjs/utils/crossApex.js.map +0 -1
- package/lib/module/ui/context/inSessionTokenRefresh.js +0 -238
- package/lib/module/ui/context/inSessionTokenRefresh.js.map +0 -1
- package/lib/module/ui/context/silentSessionRestore.js +0 -53
- package/lib/module/ui/context/silentSessionRestore.js.map +0 -1
- package/lib/module/ui/hooks/useWebSSO.js +0 -24
- package/lib/module/ui/hooks/useWebSSO.js.map +0 -1
- package/lib/module/ui/session/projectSessionState.js +0 -79
- package/lib/module/ui/session/projectSessionState.js.map +0 -1
- package/lib/module/ui/session/sessionClientHost.js +0 -26
- package/lib/module/ui/session/sessionClientHost.js.map +0 -1
- package/lib/module/ui/utils/activeAuthuser.js +0 -134
- package/lib/module/ui/utils/activeAuthuser.js.map +0 -1
- package/lib/module/utils/crossApex.js +0 -69
- package/lib/module/utils/crossApex.js.map +0 -1
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +0 -106
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +0 -36
- package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +0 -19
- package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +0 -45
- package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +0 -14
- package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +0 -67
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +0 -1
- package/lib/typescript/commonjs/utils/crossApex.d.ts +0 -55
- package/lib/typescript/commonjs/utils/crossApex.d.ts.map +0 -1
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +0 -106
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +0 -1
- package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +0 -36
- package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +0 -1
- package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +0 -19
- package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +0 -1
- package/lib/typescript/module/ui/session/projectSessionState.d.ts +0 -45
- package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +0 -1
- package/lib/typescript/module/ui/session/sessionClientHost.d.ts +0 -14
- package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +0 -1
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +0 -67
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +0 -1
- package/lib/typescript/module/utils/crossApex.d.ts +0 -55
- package/lib/typescript/module/utils/crossApex.d.ts.map +0 -1
- package/src/ui/context/inSessionTokenRefresh.ts +0 -264
- package/src/ui/context/silentSessionRestore.ts +0 -53
- package/src/ui/hooks/useWebSSO.ts +0 -23
- package/src/ui/session/__tests__/projectSessionState.test.ts +0 -114
- package/src/ui/session/__tests__/sessionClientHost.test.ts +0 -43
- package/src/ui/session/projectSessionState.ts +0 -85
- package/src/ui/session/sessionClientHost.ts +0 -27
- package/src/ui/utils/activeAuthuser.ts +0 -142
- package/src/utils/crossApex.ts +0 -75
|
@@ -4,64 +4,47 @@ import {
|
|
|
4
4
|
useCallback,
|
|
5
5
|
useContext,
|
|
6
6
|
useEffect,
|
|
7
|
-
useLayoutEffect,
|
|
8
7
|
useMemo,
|
|
9
8
|
useRef,
|
|
10
9
|
useState,
|
|
11
10
|
type ReactNode,
|
|
12
11
|
} from 'react';
|
|
13
12
|
import { OxyServices, oxyClient } from '@oxyhq/core';
|
|
14
|
-
import type {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
import type {
|
|
14
|
+
User,
|
|
15
|
+
ApiError,
|
|
16
|
+
SessionLoginResponse,
|
|
17
|
+
AccountNode,
|
|
18
|
+
CreateAccountInput,
|
|
19
|
+
ClientSession,
|
|
20
|
+
AuthStateStore,
|
|
21
|
+
PersistedAuthState,
|
|
22
|
+
} from '@oxyhq/core';
|
|
18
23
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
ssoGuardKey,
|
|
25
|
-
ssoDestKey,
|
|
26
|
-
ssoNoSessionKey,
|
|
27
|
-
ssoAttemptedKey,
|
|
28
|
-
isCentralIdPOrigin,
|
|
29
|
-
guardActive,
|
|
30
|
-
allowSsoBounce,
|
|
31
|
-
ssoNavigate,
|
|
32
|
-
buildSsoBounceUrl,
|
|
33
|
-
consumeSsoReturn,
|
|
24
|
+
KeyManager,
|
|
25
|
+
runSessionColdBoot,
|
|
26
|
+
installAuthRefreshHandler,
|
|
27
|
+
startTokenRefreshScheduler,
|
|
28
|
+
logger as loggerUtil,
|
|
34
29
|
} from '@oxyhq/core';
|
|
35
|
-
import { toast } from '@oxyhq/bloom';
|
|
36
30
|
import { useAuthStore, type AuthState } from '../stores/authStore';
|
|
37
31
|
import { useShallow } from 'zustand/react/shallow';
|
|
38
32
|
import type { UseFollowHook } from '../hooks/useFollow.types';
|
|
39
33
|
import { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
40
34
|
import { useSessionManagement } from '../hooks/useSessionManagement';
|
|
41
|
-
import { useAuthOperations,
|
|
35
|
+
import { useAuthOperations, clearPersistedAuthSafe } from './hooks/useAuthOperations';
|
|
42
36
|
import { useDeviceManagement } from '../hooks/useDeviceManagement';
|
|
43
37
|
import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
|
|
44
|
-
import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
|
|
45
|
-
import {
|
|
46
|
-
readActiveAuthuser,
|
|
47
|
-
clearSignedOut,
|
|
48
|
-
isSilentRestoreSuppressed,
|
|
49
|
-
markSignedOut,
|
|
50
|
-
clearSsoBounceState,
|
|
51
|
-
} from '../utils/activeAuthuser';
|
|
52
38
|
import type { RouteName } from '../navigation/routes';
|
|
53
39
|
import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
|
|
54
40
|
import { useQueryClient } from '@tanstack/react-query';
|
|
55
41
|
import { clearQueryCache } from '../hooks/queryClient';
|
|
56
42
|
import { useAvatarPicker } from '../hooks/useAvatarPicker';
|
|
57
43
|
import { useAccountStore } from '../stores/accountStore';
|
|
58
|
-
import {
|
|
59
|
-
import { isWebBrowser } from '../hooks/useWebSSO';
|
|
60
|
-
import { isCrossApexWeb, CrossApexDirectSignInError } from '../../utils/crossApex';
|
|
61
|
-
import { createInSessionRefreshHandler, startTokenRefreshScheduler } from './inSessionTokenRefresh';
|
|
62
|
-
import { mintSessionViaPerApexIframe } from './silentSessionRestore';
|
|
44
|
+
import { isWebBrowser } from '../utils/isWebBrowser';
|
|
63
45
|
import {
|
|
64
46
|
createSessionClient,
|
|
47
|
+
createPlatformAuthStateStore,
|
|
65
48
|
deviceStateToClientSessions,
|
|
66
49
|
activeSessionIdOf,
|
|
67
50
|
activeUserOf,
|
|
@@ -81,16 +64,12 @@ export interface OxyContextState {
|
|
|
81
64
|
/**
|
|
82
65
|
* Whether the initial auth determination has concluded.
|
|
83
66
|
*
|
|
84
|
-
* `false` from mount until the FIRST
|
|
67
|
+
* `false` from mount until the FIRST device-first cold boot resolves —
|
|
85
68
|
* during that window `isAuthenticated: false` is UNDETERMINED, not a
|
|
86
|
-
* definitive "logged out". Flips to `true` exactly once the
|
|
69
|
+
* definitive "logged out". Flips to `true` exactly once the boot concludes
|
|
87
70
|
* (a session was committed OR none exists) and never reverts. Consumers should
|
|
88
71
|
* defer their first auth-dependent fetch until this is `true` so a cold-boot
|
|
89
72
|
* web reload with an existing session does not fetch anonymous data.
|
|
90
|
-
*
|
|
91
|
-
* On native, cold boot runs only the `stored-session` step, so this resolves
|
|
92
|
-
* promptly. It is set in the restore `finally`, so the success, no-session,
|
|
93
|
-
* and error paths all reach `true` — it can never get stuck `false`.
|
|
94
73
|
*/
|
|
95
74
|
isAuthResolved: boolean;
|
|
96
75
|
isStorageReady: boolean;
|
|
@@ -111,14 +90,11 @@ export interface OxyContextState {
|
|
|
111
90
|
* Sign in with a username/email + password.
|
|
112
91
|
*
|
|
113
92
|
* Commits a successful session into context state through the SAME path the
|
|
114
|
-
*
|
|
115
|
-
* the
|
|
116
|
-
* caller can branch on the
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* This is the keyless native sign-in path for the slimmed Accounts app, which
|
|
121
|
-
* no longer holds a local cryptographic identity key.
|
|
93
|
+
* QR device-flow and cold boot use (so `isAuthenticated` / `user` update and
|
|
94
|
+
* the rotating refresh family is persisted). Returns a discriminated result
|
|
95
|
+
* so the caller can branch on the two-factor-required case — which creates NO
|
|
96
|
+
* session; the caller completes the 2FA challenge with the returned
|
|
97
|
+
* `loginToken` via {@link OxyContextState.completeTwoFactorSignIn}.
|
|
122
98
|
*/
|
|
123
99
|
signInWithPassword: (
|
|
124
100
|
identifier: string,
|
|
@@ -127,8 +103,21 @@ export interface OxyContextState {
|
|
|
127
103
|
) => Promise<PasswordSignInResult>;
|
|
128
104
|
|
|
129
105
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
106
|
+
* Complete a 2FA-gated password sign-in started by {@link signInWithPassword}.
|
|
107
|
+
* Presents the short-lived `loginToken` with a TOTP `token` or a `backupCode`;
|
|
108
|
+
* on success the session is committed exactly like a one-step sign-in.
|
|
109
|
+
*/
|
|
110
|
+
completeTwoFactorSignIn: (params: {
|
|
111
|
+
loginToken: string;
|
|
112
|
+
token?: string;
|
|
113
|
+
backupCode?: string;
|
|
114
|
+
deviceName?: string;
|
|
115
|
+
}) => Promise<void>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Commit a session obtained out-of-band (the "Sign in with Oxy" QR device
|
|
119
|
+
* flow). Plants tokens, persists the rotating refresh family, registers the
|
|
120
|
+
* account into the device set, and hydrates the full user profile.
|
|
132
121
|
*/
|
|
133
122
|
handleWebSession: (session: SessionLoginResponse) => Promise<void>;
|
|
134
123
|
|
|
@@ -158,10 +147,9 @@ export interface OxyContextState {
|
|
|
158
147
|
* supplied via the `clientId` prop. Required for the cross-app device
|
|
159
148
|
* sign-in flow: the sign-in components send it to
|
|
160
149
|
* `POST /auth/session/create` so the API can identify the requesting app by
|
|
161
|
-
* its real registered client id
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* configuration error in that case.
|
|
150
|
+
* its real registered client id. `null` when the consuming app did not
|
|
151
|
+
* configure a client id — the device sign-in flow surfaces a configuration
|
|
152
|
+
* error in that case.
|
|
165
153
|
*/
|
|
166
154
|
clientId: string | null;
|
|
167
155
|
oxyServices: OxyServices;
|
|
@@ -175,24 +163,19 @@ export interface OxyContextState {
|
|
|
175
163
|
// UX concept: the user picks an account and the WHOLE app becomes that account
|
|
176
164
|
// — a genuine, REAL-SESSION switch (`switchToAccount`), identical to switching
|
|
177
165
|
// between device sign-ins. There is NO separate "active account" concept:
|
|
178
|
-
// `user` IS the active account after a switch.
|
|
179
|
-
// delegation header is gone entirely.
|
|
166
|
+
// `user` IS the active account after a switch.
|
|
180
167
|
/** Every account the caller can access — own personal root, owned, and shared — from `listAccounts()`. */
|
|
181
168
|
accounts: AccountNode[];
|
|
182
169
|
/**
|
|
183
|
-
* Switch the active session INTO an account from the {@link accounts} graph
|
|
184
|
-
* (a managed org/project/bot, or an account shared with the caller).
|
|
170
|
+
* Switch the active session INTO an account from the {@link accounts} graph.
|
|
185
171
|
*
|
|
186
|
-
* Uniform with every other account switch: if the account is already on
|
|
187
|
-
*
|
|
172
|
+
* Uniform with every other account switch: if the account is already on this
|
|
173
|
+
* device's multi-account set, switches straight through the same
|
|
188
174
|
* server-authoritative `SessionClient.switchAccount()` path {@link switchSession}
|
|
189
|
-
* uses
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* account list exactly like a device sign-in from then on. Either way,
|
|
194
|
-
* afterwards `user` IS the target account, every request authenticates as
|
|
195
|
-
* it, and the account graph + all React Query data are refreshed/invalidated.
|
|
175
|
+
* uses. Only the FIRST switch into an account mints+plants a real session via
|
|
176
|
+
* `oxyServices.switchToAccount` and registers it into the device set, so it
|
|
177
|
+
* survives reload and appears in the device account list exactly like a device
|
|
178
|
+
* sign-in from then on. Either way, afterwards `user` IS the target account.
|
|
196
179
|
*/
|
|
197
180
|
switchToAccount: (accountId: string) => Promise<void>;
|
|
198
181
|
refreshAccounts: () => Promise<void>;
|
|
@@ -204,23 +187,17 @@ const OxyContext = createContext<OxyContextState | null>(null);
|
|
|
204
187
|
/**
|
|
205
188
|
* Result of {@link OxyContextState.signInWithPassword}.
|
|
206
189
|
*
|
|
207
|
-
* `'ok'` — the password was accepted and the
|
|
208
|
-
*
|
|
209
|
-
* use), so `isAuthenticated` / `user` are updated and the session is durably persisted;
|
|
210
|
-
* the caller can proceed (e.g. navigate into the app).
|
|
190
|
+
* `'ok'` — the password was accepted and the session committed (so
|
|
191
|
+
* `isAuthenticated` / `user` are updated and the refresh family persisted).
|
|
211
192
|
*
|
|
212
193
|
* `'2fa_required'` — the account has two-factor auth enabled, so NO session was
|
|
213
|
-
* created.
|
|
214
|
-
*
|
|
194
|
+
* created. Complete the challenge with the returned short-lived `loginToken`
|
|
195
|
+
* via {@link OxyContextState.completeTwoFactorSignIn}.
|
|
215
196
|
*/
|
|
216
197
|
export type PasswordSignInResult =
|
|
217
198
|
| { status: 'ok' }
|
|
218
199
|
| { status: '2fa_required'; loginToken: string };
|
|
219
200
|
|
|
220
|
-
// Active-authuser persistence helpers (web localStorage; native no-op) live in
|
|
221
|
-
// `../utils/activeAuthuser` so the session-management and auth-operations hooks
|
|
222
|
-
// can share them without re-importing this 1k-line context file.
|
|
223
|
-
|
|
224
201
|
export interface OxyContextProviderProps {
|
|
225
202
|
children: ReactNode;
|
|
226
203
|
oxyServices?: OxyServices;
|
|
@@ -238,126 +215,52 @@ export interface OxyContextProviderProps {
|
|
|
238
215
|
}
|
|
239
216
|
|
|
240
217
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* (established by a prior `/sso` bounce). On the common reload of a logged-out
|
|
246
|
-
* tab — or a tab that restores via the now-earlier stored-session step — the
|
|
247
|
-
* iframe never posts a message, so the full wait would be dead latency in front
|
|
248
|
-
* of the terminal `/sso` bounce. `silentSignIn` already fails fast on a load
|
|
249
|
-
* error via `iframe.onerror`; this caps the no-message case. 2.5s is well above
|
|
250
|
-
* a same-origin iframe handshake without blocking cold boot for several seconds.
|
|
251
|
-
*/
|
|
252
|
-
const SILENT_IFRAME_TIMEOUT = 2500;
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Per-step fail-fast budget (ms) for the native shared-key cold-boot step
|
|
256
|
-
* (`signInWithSharedIdentity`).
|
|
257
|
-
*
|
|
258
|
-
* That step does a challenge round-trip plus a verify against the shared
|
|
259
|
-
* cross-app identity key; if the device holds no shared identity it returns
|
|
260
|
-
* `null` quickly, but a slow/offline network must not let it block the cold
|
|
261
|
-
* boot. 8s mirrors the stored-session bearer-validation budget
|
|
262
|
-
* (`VALIDATION_TIMEOUT`) since both perform comparable bounded network work; on
|
|
263
|
-
* expiry the step resolves to `null` and cold boot falls through (native then
|
|
264
|
-
* reaches the unauthenticated backstop, web never runs this step at all).
|
|
265
|
-
*/
|
|
266
|
-
const SHARED_KEY_SIGNIN_TIMEOUT = 8000;
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* HARD overall deadline (ms) for the entire cold-boot step loop —
|
|
270
|
-
* defense-in-depth so a single non-settling step can NEVER hang auth resolution
|
|
271
|
-
* forever (the production regression this guards against: a browser silent
|
|
272
|
-
* credential/iframe probe that ignored its own abort signal left a step's
|
|
273
|
-
* promise unsettled, so `runColdBoot` never advanced to the terminal `/sso`
|
|
274
|
-
* bounce and auth hung indefinitely).
|
|
275
|
-
*
|
|
276
|
-
* Every step ALREADY bounds its own network work (the stored-session bearer
|
|
277
|
-
* validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT` plus its hard
|
|
278
|
-
* settle). On a healthy load the FIRST recovering step wins in a
|
|
279
|
-
* single round-trip (1–3s) and the chain short-circuits long before this fires.
|
|
280
|
-
* This budget only trips when one of those per-step bounds regresses.
|
|
281
|
-
*
|
|
282
|
-
* 20s is the chosen value: comfortably ABOVE the worst-case bounded
|
|
283
|
-
* stored-session path under transient slowness (the 8s parallel validation
|
|
284
|
-
* window plus a `switchSession` round-trip) so a genuinely slow-but-healthy
|
|
285
|
-
* reload is never cut off, yet well BELOW the ~28–30s the previous
|
|
286
|
-
* probe-first ordering took — and, critically, finite, so the user can never
|
|
287
|
-
* sit on an indefinite spinner. When the deadline trips, `runColdBoot` keeps
|
|
288
|
-
* iterating to the terminal `sso-bounce` step (whose navigation side effect
|
|
289
|
-
* runs synchronously), so a genuine no-local-session first visit STILL reaches
|
|
290
|
-
* the cross-domain `/sso` fallback. Native runs only the stored-session step,
|
|
291
|
-
* which is bounded well under this, so the deadline never alters native flow.
|
|
218
|
+
* Fallback client-session validity window (ms) — 7 days — applied when a
|
|
219
|
+
* committed session does not carry an explicit `expiresAt`. This is only a
|
|
220
|
+
* local display hint for the multi-session store; the server remains the source
|
|
221
|
+
* of truth for actual session expiry.
|
|
292
222
|
*/
|
|
293
|
-
const
|
|
223
|
+
const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
294
224
|
|
|
295
225
|
/**
|
|
296
|
-
* How long cold boot WAITS for the post-
|
|
297
|
-
* (`addCurrentAccount` + `start` + `syncFromClient`) before it resolves auth
|
|
298
|
-
*
|
|
226
|
+
* How long the cold boot WAITS for the post-boot SessionClient handoff
|
|
227
|
+
* (`addCurrentAccount` + `start` + `syncFromClient`) before it resolves auth and
|
|
228
|
+
* stops blocking. Once a boot step planted a token the user is already
|
|
299
229
|
* authenticated — the handoff only populates the multi-account set and the
|
|
300
230
|
* server-authoritative active account, and those also arrive via the socket
|
|
301
|
-
* subscription
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* sequential `HttpService`-bounded REST calls could push auth resolution up to
|
|
305
|
-
* ~10s past the ladder's own budget, reintroducing the exact spinner-stall
|
|
306
|
-
* `COLD_BOOT_OVERALL_DEADLINE` exists to prevent. On the normal fast path the
|
|
307
|
-
* handoff completes well within this, so the correct account shows with no
|
|
308
|
-
* flash.
|
|
231
|
+
* subscription. So on a slow backend we stop AWAITING the handoff here (it keeps
|
|
232
|
+
* running in the background and projects when it lands) rather than delaying
|
|
233
|
+
* auth resolution.
|
|
309
234
|
*/
|
|
310
235
|
const SESSION_HANDOFF_DEADLINE = 6000;
|
|
311
236
|
|
|
312
|
-
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
* a local display/bookkeeping hint for the multi-session store; the server
|
|
329
|
-
* remains the source of truth for actual session expiry. Used in the refresh
|
|
330
|
-
* cookie restore, stored-session restore, and web-SSO session paths.
|
|
331
|
-
*/
|
|
332
|
-
const DEFAULT_SESSION_VALIDITY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Minimum interval (ms) between visibility-driven IdP `/auth/session-check`
|
|
336
|
-
* probes. Debounces the hidden-iframe session check so rapid tab focus/blur
|
|
337
|
-
* cycles can't spawn a check-iframe storm; at most one probe runs per window.
|
|
338
|
-
*/
|
|
339
|
-
const IDP_SESSION_CHECK_COOLDOWN = 30000;
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Hard timeout (ms) for a single visibility-driven IdP `/auth/session-check`
|
|
343
|
-
* iframe. If the IdP never posts a `oxy-session-check` message back, the iframe
|
|
344
|
-
* and its listener are torn down after this budget so a non-responsive check
|
|
345
|
-
* can never leak an iframe or a `message` listener.
|
|
346
|
-
*/
|
|
347
|
-
const IDP_SESSION_CHECK_TIMEOUT = 5000;
|
|
237
|
+
/** The internal commit input — a session plus the device-first extras that are
|
|
238
|
+
* not on the public `SessionLoginResponse` (a rotating refresh token, a device
|
|
239
|
+
* token). All extras optional so `SessionLoginResponse` is assignable. */
|
|
240
|
+
interface CommitInput {
|
|
241
|
+
sessionId: string;
|
|
242
|
+
accessToken?: string;
|
|
243
|
+
refreshToken?: string;
|
|
244
|
+
deviceId?: string;
|
|
245
|
+
expiresAt?: string;
|
|
246
|
+
userId?: string;
|
|
247
|
+
/** Minimal user carried by the sign-in response; a best-effort fallback used
|
|
248
|
+
* only when the full-profile fetch fails. Both `SessionLoginResponse.user`
|
|
249
|
+
* (structured `name`) and the login-result user (`{id, username?, avatar?}`)
|
|
250
|
+
* are assignable. */
|
|
251
|
+
user?: { id: string; username?: string; avatar?: string };
|
|
252
|
+
}
|
|
348
253
|
|
|
349
254
|
function getHttpStatus(error: unknown): number | undefined {
|
|
350
255
|
if (!error || typeof error !== 'object') {
|
|
351
256
|
return undefined;
|
|
352
257
|
}
|
|
353
|
-
|
|
354
258
|
if ('status' in error) {
|
|
355
259
|
const status = (error as { status?: unknown }).status;
|
|
356
260
|
if (typeof status === 'number') {
|
|
357
261
|
return status;
|
|
358
262
|
}
|
|
359
263
|
}
|
|
360
|
-
|
|
361
264
|
if ('response' in error) {
|
|
362
265
|
const response = (error as { response?: unknown }).response;
|
|
363
266
|
if (response && typeof response === 'object' && 'status' in response) {
|
|
@@ -367,7 +270,6 @@ function getHttpStatus(error: unknown): number | undefined {
|
|
|
367
270
|
}
|
|
368
271
|
}
|
|
369
272
|
}
|
|
370
|
-
|
|
371
273
|
return undefined;
|
|
372
274
|
}
|
|
373
275
|
|
|
@@ -375,58 +277,12 @@ function isUnauthorizedStatus(error: unknown): boolean {
|
|
|
375
277
|
return getHttpStatus(error) === 401;
|
|
376
278
|
}
|
|
377
279
|
|
|
378
|
-
/**
|
|
379
|
-
* Whether `idpOrigin` is a same-site, first-party host of the current page —
|
|
380
|
-
* i.e. it shares the page's registrable apex (last two labels), so a "no
|
|
381
|
-
* session" answer from its `/auth/session-check` iframe is authoritative for
|
|
382
|
-
* THIS app and may force a local sign-out.
|
|
383
|
-
*
|
|
384
|
-
* On a cross-site IdP (or any host whose relationship to the page can't be
|
|
385
|
-
* positively established) this returns `false`, so the visibility-driven check
|
|
386
|
-
* may surface a session-ended toast but MUST NOT clear local state — a
|
|
387
|
-
* third-party / undetermined IdP answer can never force logout. Returns `false`
|
|
388
|
-
* off-browser.
|
|
389
|
-
*/
|
|
390
|
-
function isSameSiteIdP(idpOrigin: string): boolean {
|
|
391
|
-
// Native defines a global `window` but no `window.location`; guard the
|
|
392
|
-
// latter so reading `.hostname` can never throw off-browser. (Only reachable
|
|
393
|
-
// from the web-only visibility check, but kept robust for parity.)
|
|
394
|
-
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
let idpHostname: string;
|
|
398
|
-
try {
|
|
399
|
-
idpHostname = new URL(idpOrigin).hostname;
|
|
400
|
-
} catch (parseError) {
|
|
401
|
-
if (__DEV__) {
|
|
402
|
-
loggerUtil.debug('Invalid IdP origin while checking same-site session status', { component: 'OxyContext' }, parseError as unknown);
|
|
403
|
-
}
|
|
404
|
-
return false;
|
|
405
|
-
}
|
|
406
|
-
const pageHostname = window.location.hostname;
|
|
407
|
-
if (!idpHostname || !pageHostname) return false;
|
|
408
|
-
if (idpHostname === pageHostname) return true;
|
|
409
|
-
const pageApex = registrableApex(pageHostname);
|
|
410
|
-
const idpApex = registrableApex(idpHostname);
|
|
411
|
-
// Require a real registrable apex (not a shared/public suffix) AND an exact
|
|
412
|
-
// apex match AND that the IdP host is the page apex itself or a subdomain of it.
|
|
413
|
-
if (!pageApex || idpApex !== pageApex) return false;
|
|
414
|
-
return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function isOnSsoCallbackPath(): boolean {
|
|
418
|
-
return isWebBrowser() && window.location.pathname === SSO_CALLBACK_PATH;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
const useBrowserLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
|
|
422
|
-
|
|
423
280
|
let cachedUseFollowHook: UseFollowHook | null = null;
|
|
424
281
|
|
|
425
282
|
const loadUseFollowHook = (): UseFollowHook => {
|
|
426
283
|
if (cachedUseFollowHook) {
|
|
427
284
|
return cachedUseFollowHook;
|
|
428
285
|
}
|
|
429
|
-
|
|
430
286
|
try {
|
|
431
287
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
432
288
|
const { useFollow } = require('../hooks/useFollow');
|
|
@@ -437,14 +293,12 @@ const loadUseFollowHook = (): UseFollowHook => {
|
|
|
437
293
|
loggerUtil.warn(
|
|
438
294
|
'useFollow hook is not available. Please import useFollow from @oxyhq/services directly.',
|
|
439
295
|
{ component: 'OxyContext', method: 'loadUseFollowHook' },
|
|
440
|
-
error
|
|
296
|
+
error,
|
|
441
297
|
);
|
|
442
298
|
}
|
|
443
|
-
|
|
444
299
|
const fallback: UseFollowHook = () => {
|
|
445
300
|
throw new Error('useFollow hook is only available in the UI bundle. Import it from @oxyhq/services.');
|
|
446
301
|
};
|
|
447
|
-
|
|
448
302
|
cachedUseFollowHook = fallback;
|
|
449
303
|
return cachedUseFollowHook;
|
|
450
304
|
}
|
|
@@ -462,33 +316,28 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
462
316
|
onError,
|
|
463
317
|
}) => {
|
|
464
318
|
const oxyServicesRef = useRef<OxyServices | null>(null);
|
|
465
|
-
|
|
466
319
|
if (!oxyServicesRef.current) {
|
|
467
320
|
if (providedOxyServices) {
|
|
468
321
|
oxyServicesRef.current = providedOxyServices;
|
|
469
322
|
} else if (baseURL) {
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
// central session store reached via `api.oxy.so`, so a single sign-in
|
|
474
|
-
// there is observed by all RPs through the opaque-code `/sso` bounce.
|
|
475
|
-
// `resolveCentralAuthUrl(authWebUrl)` returns the explicit `authWebUrl`
|
|
476
|
-
// prop when provided (explicit always wins) and the central default
|
|
477
|
-
// otherwise. This is NOT per-apex auto-detection — central SSO is
|
|
478
|
-
// deliberately central. A consumer-provided `OxyServices` instance is
|
|
479
|
-
// never mutated; only the baseURL-only construction path applies this.
|
|
480
|
-
oxyServicesRef.current = new OxyServices({
|
|
481
|
-
baseURL,
|
|
482
|
-
authWebUrl: resolveCentralAuthUrl(authWebUrl),
|
|
483
|
-
authRedirectUri,
|
|
484
|
-
});
|
|
323
|
+
// `authWebUrl` now only points the OAuth "Sign in with Oxy" third-party
|
|
324
|
+
// link builder at the central auth host; there is no cold-boot redirect.
|
|
325
|
+
oxyServicesRef.current = new OxyServices({ baseURL, authWebUrl, authRedirectUri });
|
|
485
326
|
} else {
|
|
486
327
|
throw new Error('Either oxyServices or baseURL must be provided to OxyContextProvider');
|
|
487
328
|
}
|
|
488
329
|
}
|
|
489
|
-
|
|
490
330
|
const oxyServices = oxyServicesRef.current;
|
|
491
331
|
|
|
332
|
+
// The device-first persisted auth-state store (per-origin refresh family on
|
|
333
|
+
// web; SecureStore session blob + shared-keychain device token on native).
|
|
334
|
+
// Built ONCE per provider mount.
|
|
335
|
+
const authStoreRef = useRef<AuthStateStore | null>(null);
|
|
336
|
+
if (!authStoreRef.current) {
|
|
337
|
+
authStoreRef.current = createPlatformAuthStateStore();
|
|
338
|
+
}
|
|
339
|
+
const authStore = authStoreRef.current;
|
|
340
|
+
|
|
492
341
|
const {
|
|
493
342
|
user,
|
|
494
343
|
isAuthenticated,
|
|
@@ -511,13 +360,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
511
360
|
|
|
512
361
|
const [tokenReady, setTokenReady] = useState(true);
|
|
513
362
|
const [hasAccessToken, setHasAccessToken] = useState(() => Boolean(oxyServices.getAccessToken()));
|
|
514
|
-
// Whether the FIRST cold-boot auth restore has concluded. Starts `false`
|
|
515
|
-
// (auth undetermined) and flips to `true` exactly once — monotonic, never
|
|
516
|
-
// reverts. It now flips the MOMENT a session commits (the common reload case
|
|
517
|
-
// unblocks immediately, without waiting for the rest of the cold-boot chain),
|
|
518
|
-
// with the restore `finally` as the no-session/error backstop. The ref makes
|
|
519
|
-
// the flip idempotent across both sites so the setters fire at most once. See
|
|
520
|
-
// `isAuthResolved` on the context type for the consumer contract.
|
|
521
363
|
const [authResolved, setAuthResolved] = useState(false);
|
|
522
364
|
const authResolvedRef = useRef(false);
|
|
523
365
|
const userRef = useRef<User | null>(user);
|
|
@@ -525,29 +367,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
525
367
|
userRef.current = user;
|
|
526
368
|
isAuthenticatedRef.current = isAuthenticated;
|
|
527
369
|
const [initialized, setInitialized] = useState(false);
|
|
528
|
-
const [ssoCallbackIntercepting, setSsoCallbackIntercepting] = useState(false);
|
|
529
370
|
const setAuthState = useAuthStore.setState;
|
|
530
371
|
|
|
531
372
|
// Keep the shared `oxyClient` singleton's token store in lockstep with the
|
|
532
|
-
// session owned by THIS provider's instance
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
// only `baseURL` to OxyProvider — in which case the provider owns a DIFFERENT
|
|
536
|
-
// `OxyServices` instance and the singleton would otherwise never receive the
|
|
537
|
-
// token, producing `Authorization: Bearer null`.
|
|
538
|
-
//
|
|
539
|
-
// Subscribing to `onTokensChanged` mirrors EVERY token mutation — sign-in,
|
|
540
|
-
// session restore, switch, silent refresh, and sign-out/clear — onto the
|
|
541
|
-
// singleton at the single source of truth in @oxyhq/core, with no per-app
|
|
542
|
-
// plumbing and regardless of which auth code path fired.
|
|
543
|
-
//
|
|
544
|
-
// When the app passed the singleton itself as `oxyServices` (Mention's
|
|
545
|
-
// pattern), `oxyServices === oxyClient`, so we skip the redundant self-write.
|
|
373
|
+
// session owned by THIS provider's instance (many apps build API clients
|
|
374
|
+
// against the exported singleton while passing only `baseURL` here). Skipped
|
|
375
|
+
// when the app passed the singleton itself as `oxyServices`.
|
|
546
376
|
useEffect(() => {
|
|
547
377
|
if (oxyServices === oxyClient) {
|
|
548
378
|
return;
|
|
549
379
|
}
|
|
550
|
-
|
|
551
380
|
const applyToSingleton = (accessToken: string | null) => {
|
|
552
381
|
if (accessToken) {
|
|
553
382
|
oxyClient.setTokens(accessToken);
|
|
@@ -555,12 +384,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
555
384
|
oxyClient.clearTokens();
|
|
556
385
|
}
|
|
557
386
|
};
|
|
558
|
-
|
|
559
|
-
// Seed the singleton with whatever token the instance already holds (it may
|
|
560
|
-
// have been planted synchronously before this effect ran — e.g. a token set
|
|
561
|
-
// during the same tick as mount), then keep it in sync going forward.
|
|
562
387
|
applyToSingleton(oxyServices.getAccessToken());
|
|
563
|
-
|
|
564
388
|
return oxyServices.onTokensChanged(applyToSingleton);
|
|
565
389
|
}, [oxyServices]);
|
|
566
390
|
|
|
@@ -572,38 +396,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
572
396
|
|
|
573
397
|
const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
|
|
574
398
|
|
|
575
|
-
// The app's Oxy OAuth client id surfaced on the context so the cross-app
|
|
576
|
-
// device sign-in components (SignInModal / OxyAuthScreen) can identify the
|
|
577
|
-
// requesting app to `POST /auth/session/create`. Normalized to a trimmed
|
|
578
|
-
// non-empty string, or `null` when the consumer did not configure one — the
|
|
579
|
-
// sign-in components surface a clear configuration error in that case rather
|
|
580
|
-
// than falling back to any display string.
|
|
581
399
|
const clientId = useMemo(() => {
|
|
582
400
|
const trimmed = clientIdProp?.trim();
|
|
583
401
|
return trimmed ? trimmed : null;
|
|
584
402
|
}, [clientIdProp]);
|
|
585
403
|
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
// hooks below. But it is `null` for a brief window after mount while
|
|
590
|
-
// `createPlatformStorage()` resolves (a microtask on web; a dynamic
|
|
591
|
-
// `import()` on native). Any persistence path that fires during that window
|
|
592
|
-
// — e.g. an interactive password sign-in the instant the screen mounts —
|
|
593
|
-
// would read `storage === null` and SILENTLY skip writing the session,
|
|
594
|
-
// leaving the user signed-in in-memory but with nothing to restore on
|
|
595
|
-
// reload.
|
|
596
|
-
//
|
|
597
|
-
// To make persistence robust regardless of timing we ALSO expose the storage
|
|
598
|
-
// as an awaitable promise (`getReadyStorage`). Persistence code awaits the
|
|
599
|
-
// ready instance instead of branching on the possibly-null state, so a write
|
|
600
|
-
// is never silently dropped just because it raced storage init.
|
|
404
|
+
// Local display/bookkeeping storage (session id list, language). Distinct
|
|
405
|
+
// from `authStore` (the durable credential blob). Exposed as an awaitable so
|
|
406
|
+
// a persistence write is never dropped because it raced storage init.
|
|
601
407
|
const storageRef = useRef<StorageInterface | null>(null);
|
|
602
408
|
const [storage, setStorage] = useState<StorageInterface | null>(null);
|
|
603
|
-
|
|
604
|
-
// A single, stable deferred that resolves with the initialized storage. Built
|
|
605
|
-
// lazily via a ref initializer so the resolver is captured exactly once and
|
|
606
|
-
// the promise identity is stable across renders.
|
|
607
409
|
const buildStorageDeferred = () => {
|
|
608
410
|
let resolve: (storage: StorageInterface) => void = () => undefined;
|
|
609
411
|
const promise = new Promise<StorageInterface>((res) => {
|
|
@@ -617,41 +419,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
617
419
|
}
|
|
618
420
|
const storageReady = storageReadyRef.current;
|
|
619
421
|
|
|
620
|
-
// Resolve the storage instance that is guaranteed to be ready. Returns the
|
|
621
|
-
// already-initialized instance synchronously when available, otherwise awaits
|
|
622
|
-
// the init promise. Never resolves to `null`.
|
|
623
|
-
const getReadyStorage = useCallback((): Promise<StorageInterface> => {
|
|
624
|
-
if (storageRef.current) {
|
|
625
|
-
return Promise.resolve(storageRef.current);
|
|
626
|
-
}
|
|
627
|
-
return storageReady.promise;
|
|
628
|
-
}, [storageReady]);
|
|
629
|
-
|
|
630
|
-
// Clear the durable "had a signed-in Oxy session before" hint.
|
|
631
|
-
//
|
|
632
|
-
// The hint (`storageKeys.priorSession`) is WRITTEN whenever a session is
|
|
633
|
-
// established/restored — every commit funnels through `persistSessionDurably`,
|
|
634
|
-
// and the stored-session reload winner sets it directly to backfill
|
|
635
|
-
// pre-existing installs. It lives in the SAME `storageKeyPrefix`-scoped
|
|
636
|
-
// durable store as the session ids, so it SURVIVES a session expiring; it is
|
|
637
|
-
// cleared ONLY here, on EXPLICIT full sign-out (wired into `clearAllAccountData`
|
|
638
|
-
// and the `useAuthOperations` logout paths — never the passive token-expiry
|
|
639
|
-
// path). At cold boot the hint is read into `hadPriorSession` and feeds
|
|
640
|
-
// `allowSsoBounce`: a RETURNING visitor (hint present) whose local session has
|
|
641
|
-
// lapsed still gets ONE terminal `/sso` establish bounce so a central-only
|
|
642
|
-
// cross-domain session recovers, while a truly first-time anonymous visitor is
|
|
643
|
-
// never force-redirected.
|
|
644
|
-
const clearPriorSessionHint = useCallback(async (): Promise<void> => {
|
|
645
|
-
const readyStorage = await getReadyStorage();
|
|
646
|
-
await readyStorage.removeItem(storageKeys.priorSession);
|
|
647
|
-
}, [getReadyStorage, storageKeys.priorSession]);
|
|
648
|
-
|
|
649
422
|
useEffect(() => {
|
|
650
423
|
let mounted = true;
|
|
651
424
|
createPlatformStorage()
|
|
652
425
|
.then((storageInstance) => {
|
|
653
|
-
// Resolve the ready-promise even if the component unmounted: in-flight
|
|
654
|
-
// persistence awaiting it must still complete against a real store.
|
|
655
426
|
storageRef.current = storageInstance;
|
|
656
427
|
storageReady.resolve(storageInstance);
|
|
657
428
|
if (mounted) {
|
|
@@ -661,23 +432,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
661
432
|
.catch((err) => {
|
|
662
433
|
if (mounted) {
|
|
663
434
|
logger('Failed to initialize storage', err);
|
|
664
|
-
onError?.({
|
|
665
|
-
message: 'Failed to initialize storage',
|
|
666
|
-
code: 'STORAGE_INIT_ERROR',
|
|
667
|
-
status: 500,
|
|
668
|
-
});
|
|
435
|
+
onError?.({ message: 'Failed to initialize storage', code: 'STORAGE_INIT_ERROR', status: 500 });
|
|
669
436
|
}
|
|
670
437
|
});
|
|
671
|
-
|
|
672
438
|
return () => {
|
|
673
439
|
mounted = false;
|
|
674
440
|
};
|
|
675
441
|
}, [logger, onError, storageReady]);
|
|
676
442
|
|
|
677
|
-
|
|
678
|
-
// Offline queuing is now handled by TanStack Query mutations
|
|
679
|
-
// No need for custom offline queue
|
|
680
|
-
|
|
681
443
|
const {
|
|
682
444
|
currentLanguage,
|
|
683
445
|
metadata: currentLanguageMetadata,
|
|
@@ -717,114 +479,69 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
717
479
|
queryClient,
|
|
718
480
|
});
|
|
719
481
|
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
482
|
+
// Refs so callbacks below can invoke the latest session-management functions
|
|
483
|
+
// without widening dependency arrays.
|
|
484
|
+
const switchSessionRef = useRef(switchSession);
|
|
485
|
+
switchSessionRef.current = switchSession;
|
|
486
|
+
const clearSessionStateRef = useRef(clearSessionState);
|
|
487
|
+
clearSessionStateRef.current = clearSessionState;
|
|
488
|
+
const setActiveSessionIdRef = useRef(setActiveSessionId);
|
|
489
|
+
setActiveSessionIdRef.current = setActiveSessionId;
|
|
490
|
+
const loginSuccessRef = useRef(loginSuccess);
|
|
491
|
+
loginSuccessRef.current = loginSuccess;
|
|
492
|
+
const onAuthStateChangeRef = useRef(onAuthStateChange);
|
|
493
|
+
onAuthStateChangeRef.current = onAuthStateChange;
|
|
494
|
+
|
|
495
|
+
// Flip the auth-resolution gate (`authResolved` + `tokenReady`) the moment a
|
|
496
|
+
// session commits or the boot concludes signed out. Idempotent + monotonic.
|
|
497
|
+
const markAuthResolved = useCallback(() => {
|
|
498
|
+
if (authResolvedRef.current) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
authResolvedRef.current = true;
|
|
502
|
+
setTokenReady(true);
|
|
503
|
+
setAuthResolved(true);
|
|
504
|
+
}, []);
|
|
505
|
+
const markAuthResolvedRef = useRef(markAuthResolved);
|
|
506
|
+
markAuthResolvedRef.current = markAuthResolved;
|
|
507
|
+
|
|
508
|
+
// Server-authoritative device session client. Built ONCE per `oxyServices`
|
|
509
|
+
// instance. `onUnauthenticated` (a device signout-all pushed over the socket,
|
|
510
|
+
// or bootstrapped as zero-account) clears the persisted store + local state so
|
|
511
|
+
// a reload does not try to restore a session the device no longer has.
|
|
732
512
|
const sessionClientPairRef = useRef<ReturnType<typeof createSessionClient> | null>(null);
|
|
733
513
|
if (!sessionClientPairRef.current) {
|
|
734
|
-
sessionClientPairRef.current = createSessionClient(oxyServices)
|
|
514
|
+
sessionClientPairRef.current = createSessionClient(oxyServices, authStore, () => {
|
|
515
|
+
clearPersistedAuthSafe(authStore, logger);
|
|
516
|
+
void clearSessionStateRef.current().catch((clearError) => {
|
|
517
|
+
logger('Failed to clear local state on remote sign-out', clearError);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
735
520
|
}
|
|
736
521
|
const { client: sessionClient, host: sessionClientHost } = sessionClientPairRef.current;
|
|
737
522
|
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
// the recovered account into the device set
|
|
742
|
-
// itself (`sessionClient.addCurrentAccount()`). The cold-boot post-ladder
|
|
743
|
-
// handoff ALSO registers, but only as a FALLBACK for the `stored-session`
|
|
744
|
-
// step, which commits through a completely different path
|
|
745
|
-
// (`switchSessionRef`) and never touches `sessionClient` on its own. This
|
|
746
|
-
// ref lets the handoff detect "did a ladder step already register this
|
|
747
|
-
// boot?" and skip a redundant second `POST /session/device/add`. Reset at
|
|
748
|
-
// the start of every cold-boot pass.
|
|
749
|
-
const registeredDuringBootRef = useRef(false);
|
|
750
|
-
|
|
751
|
-
// Projects `client.getState()` onto the exposed `sessions` / `activeSessionId`
|
|
752
|
-
// / `user` via the SAME setters the existing cold-boot/session-management
|
|
753
|
-
// paths use. This is the SOLE authority for both a locally-initiated mutation
|
|
754
|
-
// (switch/logout resolving here) AND a REMOTELY-pushed `session_state` (the
|
|
755
|
-
// `device:<deviceId>` socket owned by `client.start()`, subscribed to below) —
|
|
756
|
-
// there is no per-domain `useSessionSocket` anymore.
|
|
757
|
-
//
|
|
758
|
-
// GAP FIX (Task 4): when the pushed/bootstrapped state reports ZERO device
|
|
759
|
-
// accounts, this device has been fully signed out — either by the LOCAL
|
|
760
|
-
// `logout()`/`logoutAll()` mutation (which also runs its own explicit
|
|
761
|
-
// cleanup) or, just as importantly, by a REMOTE actor (another tab/device
|
|
762
|
-
// removing the last session, or an admin revoking every device account)
|
|
763
|
-
// pushing that same empty state over the socket. The prior per-domain
|
|
764
|
-
// `useSessionSocket` forced a local sign-out in this situation
|
|
765
|
-
// (`triggerLocalSignOut`); without an equivalent here a remote full sign-out
|
|
766
|
-
// would leave `user`/`isAuthenticated`/the bearer token stale until the next
|
|
767
|
-
// 401. Route through the SAME `clearSessionState()` a local full sign-out
|
|
768
|
-
// uses (authStore reset, token clear, query-cache clear, storage clear) —
|
|
769
|
-
// idempotent with the local-logout caller's own explicit call.
|
|
770
|
-
//
|
|
771
|
-
// DELIBERATE-SIGN-OUT GUARD (Task 5): a zero-account state — however it
|
|
772
|
-
// arrived — is just as terminal as a local `logout()`/`logoutAll()`, so it
|
|
773
|
-
// sets the SAME durable "deliberately signed out" flag (web-only,
|
|
774
|
-
// best-effort) that those two callers set directly. Without this, a remote
|
|
775
|
-
// full wipe would leave the flag unset, and the `silent-iframe` cold-boot
|
|
776
|
-
// step could silently re-mint a session from a still-live per-apex
|
|
777
|
-
// `fedcm_session` cookie right after this device was authoritatively signed
|
|
778
|
-
// out everywhere.
|
|
779
|
-
//
|
|
780
|
-
// REMOTE-SIGN-OUT SUPPRESSION FIX (review H1): the terminal `sso-bounce`
|
|
781
|
-
// cold-boot step is gated on the durable prior-session hint
|
|
782
|
-
// (`allowSsoBounce` = `hasPriorSession || hasLocalSession`), NOT on the
|
|
783
|
-
// deliberately-signed-out flag set above. Leaving that hint in place after a
|
|
784
|
-
// REMOTE full sign-out means the next reload still performs one terminal
|
|
785
|
-
// `/sso` establish bounce, which can silently re-mint a session from a
|
|
786
|
-
// still-live central `fedcm_session` — signing the user back in right after
|
|
787
|
-
// they were signed out everywhere. Clear the SAME cleanup set the LOCAL
|
|
788
|
-
// `logout()`/`logoutAll()` paths use (`useAuthOperations.ts`) — SSO bounce
|
|
789
|
-
// state + the prior-session hint — so a remote sign-out is indistinguishable
|
|
790
|
-
// from a local one to the next cold boot.
|
|
523
|
+
// Projects `client.getState()` onto the exposed `sessions`/`activeSessionId`/
|
|
524
|
+
// `user`. Sole authority for both locally-initiated mutations (switch/logout)
|
|
525
|
+
// AND remotely-pushed `session_state` over the `device:<deviceId>` socket.
|
|
791
526
|
const syncFromClient = useCallback(async (): Promise<void> => {
|
|
792
527
|
const state = sessionClient.getState();
|
|
793
528
|
if (state === null) {
|
|
794
|
-
// INERT: no session state has been bootstrapped (client.start() has not
|
|
795
|
-
// run). Never overwrite the existing cold-boot-driven state.
|
|
796
529
|
return;
|
|
797
530
|
}
|
|
798
531
|
if (state.accounts.length === 0) {
|
|
799
532
|
sessionClientHost.setCurrentAccountId(null);
|
|
800
|
-
|
|
801
|
-
markSignedOut();
|
|
802
|
-
}
|
|
803
|
-
clearSsoBounceState();
|
|
804
|
-
clearPriorSessionHintSafe(clearPriorSessionHint, logger);
|
|
533
|
+
clearPersistedAuthSafe(authStore, logger);
|
|
805
534
|
await clearSessionState();
|
|
806
535
|
return;
|
|
807
536
|
}
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
// mutation call — and each invocation captures `state` before an async
|
|
811
|
-
// profile fetch (`getUsersByIds`). `SessionClient`'s own state is
|
|
812
|
-
// monotonic by revision, but this projection previously was not: a
|
|
813
|
-
// SLOWER, OLDER fetch resolving AFTER a newer one would still apply its
|
|
814
|
-
// now-stale captured `state`, clobbering `user`/`sessions`/
|
|
815
|
-
// `activeSessionId` back to an outdated account. Capture the revision
|
|
816
|
-
// this fetch is FOR, then after the await, re-read the client's current
|
|
817
|
-
// state and bail if it has since moved past the captured revision — a
|
|
818
|
-
// fresher call has already applied (or will apply) the current truth.
|
|
537
|
+
// Last-write-wins guard: capture the revision this projection is for, then
|
|
538
|
+
// after the async profile fetch bail if a fresher state has landed.
|
|
819
539
|
const capturedRevision = state.revision;
|
|
820
540
|
const ids = accountIdsOf(state);
|
|
821
541
|
let users: User[] = [];
|
|
822
542
|
try {
|
|
823
543
|
users = ids.length > 0 ? await oxyServices.getUsersByIds(ids) : [];
|
|
824
544
|
} catch (fetchError) {
|
|
825
|
-
// Invoked fire-and-forget from the socket `notify()` subscription, so a
|
|
826
|
-
// rejected profile fetch would surface as an unhandled rejection. Log and
|
|
827
|
-
// bail; the next `notify()`/mutation will re-project the current truth.
|
|
828
545
|
logger('Failed to resolve account profiles during syncFromClient', fetchError);
|
|
829
546
|
return;
|
|
830
547
|
}
|
|
@@ -844,13 +561,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
844
561
|
oxyServices,
|
|
845
562
|
sessionClient,
|
|
846
563
|
sessionClientHost,
|
|
564
|
+
authStore,
|
|
847
565
|
updateSessions,
|
|
848
566
|
setActiveSessionId,
|
|
849
567
|
loginSuccess,
|
|
850
568
|
clearSessionState,
|
|
851
|
-
clearPriorSessionHint,
|
|
852
569
|
logger,
|
|
853
570
|
]);
|
|
571
|
+
const syncFromClientRef = useRef(syncFromClient);
|
|
572
|
+
syncFromClientRef.current = syncFromClient;
|
|
854
573
|
|
|
855
574
|
useEffect(() => {
|
|
856
575
|
return sessionClient.subscribe(() => {
|
|
@@ -858,19 +577,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
858
577
|
});
|
|
859
578
|
}, [sessionClient, syncFromClient]);
|
|
860
579
|
|
|
861
|
-
const {
|
|
862
|
-
signIn,
|
|
863
|
-
logout,
|
|
864
|
-
logoutAll,
|
|
865
|
-
} = useAuthOperations({
|
|
580
|
+
const { signIn, logout, logoutAll } = useAuthOperations({
|
|
866
581
|
oxyServices,
|
|
582
|
+
store: authStore,
|
|
867
583
|
storage,
|
|
868
584
|
activeSessionId,
|
|
869
585
|
setActiveSessionId,
|
|
870
586
|
updateSessions,
|
|
871
587
|
saveActiveSessionId,
|
|
872
588
|
clearSessionState,
|
|
873
|
-
clearPriorSessionHint,
|
|
874
589
|
switchSession,
|
|
875
590
|
sessionClient,
|
|
876
591
|
syncFromClient,
|
|
@@ -884,12 +599,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
884
599
|
logger,
|
|
885
600
|
});
|
|
886
601
|
|
|
887
|
-
// Clear all account data (sessions, cache, etc.)
|
|
888
602
|
const clearAllAccountData = useCallback(async (): Promise<void> => {
|
|
889
|
-
// Clear TanStack Query cache (in-memory)
|
|
890
603
|
queryClient.clear();
|
|
891
|
-
|
|
892
|
-
// Clear persisted query cache
|
|
893
604
|
if (storage) {
|
|
894
605
|
try {
|
|
895
606
|
await clearQueryCache(storage);
|
|
@@ -897,22 +608,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
897
608
|
logger('Failed to clear persisted query cache', error);
|
|
898
609
|
}
|
|
899
610
|
}
|
|
900
|
-
|
|
901
|
-
// Clear session state (sessions, activeSessionId, storage)
|
|
902
611
|
await clearSessionState();
|
|
903
|
-
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
// `/sso` bounce). NOT done on the passive token-expiry path, so an expired
|
|
907
|
-
// session still recovers via a returning-user bounce.
|
|
908
|
-
await clearPriorSessionHint();
|
|
909
|
-
|
|
910
|
-
// Reset account store
|
|
612
|
+
// Explicit FULL wipe: also drop the persisted refresh family so a reload
|
|
613
|
+
// finds nothing to restore.
|
|
614
|
+
await authStore.clear();
|
|
911
615
|
useAccountStore.getState().reset();
|
|
912
|
-
|
|
913
|
-
// Clear HTTP service cache
|
|
914
616
|
oxyServices.clearCache();
|
|
915
|
-
}, [queryClient, storage, clearSessionState,
|
|
617
|
+
}, [queryClient, storage, clearSessionState, authStore, logger, oxyServices]);
|
|
916
618
|
|
|
917
619
|
const { getDeviceSessions, logoutAllDeviceSessions, updateDeviceName } = useDeviceManagement({
|
|
918
620
|
oxyServices,
|
|
@@ -924,15 +626,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
924
626
|
|
|
925
627
|
const useFollowHook = loadUseFollowHook();
|
|
926
628
|
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
clearSessionStateRef.current = clearSessionState;
|
|
629
|
+
// Token-change side effects: an invalidated bearer (HttpService clears tokens
|
|
630
|
+
// on an unrecoverable 401 and emits `null`) must locally sign out an
|
|
631
|
+
// authenticated user so `isAuthenticated` never lingers true with no token.
|
|
632
|
+
// The persisted store is NOT cleared here — the refresh handler already
|
|
633
|
+
// cleared it if the family was revoked; a transient null leaves it intact so a
|
|
634
|
+
// later reload can still restore.
|
|
934
635
|
const clearingInvalidTokenRef = useRef(false);
|
|
935
|
-
|
|
936
636
|
useEffect(() => {
|
|
937
637
|
const handleTokenChange = (accessToken: string | null) => {
|
|
938
638
|
setHasAccessToken(Boolean(accessToken));
|
|
@@ -940,7 +640,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
940
640
|
setTokenReady(true);
|
|
941
641
|
return;
|
|
942
642
|
}
|
|
943
|
-
|
|
944
643
|
if (userRef.current || isAuthenticatedRef.current) {
|
|
945
644
|
setTokenReady(false);
|
|
946
645
|
if (clearingInvalidTokenRef.current) {
|
|
@@ -959,1175 +658,303 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
959
658
|
});
|
|
960
659
|
return;
|
|
961
660
|
}
|
|
962
|
-
|
|
963
661
|
if (authResolvedRef.current) {
|
|
964
662
|
setTokenReady(true);
|
|
965
663
|
}
|
|
966
664
|
};
|
|
967
|
-
|
|
968
665
|
handleTokenChange(oxyServices.getAccessToken());
|
|
969
666
|
return oxyServices.onTokensChanged(handleTokenChange);
|
|
970
667
|
}, [logger, oxyServices]);
|
|
971
668
|
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
// a zombie logged-in state whose cross-apex feed calls 401-looped. Here we (1)
|
|
978
|
-
// install a handler that re-mints a fresh token WITHOUT a reload using the SAME
|
|
979
|
-
// durable silent-restore arms cold boot uses, and (2) start a proactive
|
|
980
|
-
// scheduler that refreshes ~60s before expiry (and on tab-focus / app-
|
|
981
|
-
// foreground) so the common case never even hits the reactive 401 path. The
|
|
982
|
-
// linked client (`createLinkedClient`) delegates its refresh back to this owner
|
|
983
|
-
// handler, so it is fixed for free.
|
|
984
|
-
//
|
|
985
|
-
// Runs once per `oxyServices` instance (stable, ref-constructed), and BEFORE
|
|
986
|
-
// any cold-boot request can 401: cold boot is gated on async storage init, so
|
|
987
|
-
// this synchronous mount effect installs the handler first. On cleanup the
|
|
988
|
-
// handler is detached and the scheduler torn down (timer + foreground listener)
|
|
989
|
-
// so nothing leaks across a provider remount. `setAuthRefreshHandler` is
|
|
990
|
-
// optional-chained to tolerate partial test stubs.
|
|
669
|
+
// Unified in-session refresh (SDK-owned; every RP inherits it). Installs the
|
|
670
|
+
// ONE core refresh handler (rotate the persisted refresh family; native falls
|
|
671
|
+
// back to the shared keychain) plus the proactive scheduler that refreshes
|
|
672
|
+
// ~60s before expiry and on tab-focus. Replaces the deleted services-local
|
|
673
|
+
// `inSessionTokenRefresh` module.
|
|
991
674
|
useEffect(() => {
|
|
992
|
-
|
|
675
|
+
const dispose = installAuthRefreshHandler({ oxy: oxyServices, store: authStore });
|
|
993
676
|
const scheduler = startTokenRefreshScheduler(oxyServices);
|
|
994
677
|
return () => {
|
|
995
678
|
scheduler.dispose();
|
|
996
|
-
|
|
679
|
+
dispose();
|
|
997
680
|
};
|
|
998
|
-
}, [oxyServices]);
|
|
999
|
-
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const persistSessionDurably = useCallback(async (sessionId: string): Promise<void> => {
|
|
1011
|
-
const readyStorage = await getReadyStorage();
|
|
1012
|
-
await readyStorage.setItem(storageKeys.activeSessionId, sessionId);
|
|
1013
|
-
const existingIds = await readyStorage.getItem(storageKeys.sessionIds);
|
|
1014
|
-
let sessionIds: string[] = [];
|
|
1015
|
-
try {
|
|
1016
|
-
sessionIds = existingIds ? JSON.parse(existingIds) : [];
|
|
1017
|
-
} catch (parseError) {
|
|
1018
|
-
logger('Failed to parse persisted session ids; replacing corrupted storage value', parseError);
|
|
1019
|
-
}
|
|
1020
|
-
if (!sessionIds.includes(sessionId)) {
|
|
1021
|
-
sessionIds.push(sessionId);
|
|
1022
|
-
await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
|
|
1023
|
-
}
|
|
1024
|
-
// A session is now durably committed — set the returning-user hint so a
|
|
1025
|
-
// future cold boot whose local session has lapsed still gets ONE `/sso`
|
|
1026
|
-
// establish bounce (see `markPriorSessionHint`). Every web commit path
|
|
1027
|
-
// (silent iframe / SSO return / password / cookie restore) funnels
|
|
1028
|
-
// through here, so this is the single chokepoint for the hint.
|
|
1029
|
-
await readyStorage.setItem(storageKeys.priorSession, '1');
|
|
1030
|
-
}, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds, storageKeys.priorSession]);
|
|
1031
|
-
|
|
1032
|
-
// Refs so the cold-boot restore can plant session state without widening its
|
|
1033
|
-
// dependency array (mirrors the existing ref pattern above).
|
|
1034
|
-
const setActiveSessionIdRef = useRef(setActiveSessionId);
|
|
1035
|
-
setActiveSessionIdRef.current = setActiveSessionId;
|
|
1036
|
-
const loginSuccessRef = useRef(loginSuccess);
|
|
1037
|
-
loginSuccessRef.current = loginSuccess;
|
|
1038
|
-
const onAuthStateChangeRef = useRef(onAuthStateChange);
|
|
1039
|
-
onAuthStateChangeRef.current = onAuthStateChange;
|
|
1040
|
-
|
|
1041
|
-
// Flip the auth-resolution gate (`authResolved` + `tokenReady`) the MOMENT a
|
|
1042
|
-
// session commits, instead of waiting for the whole cold-boot chain to finish.
|
|
1043
|
-
// Idempotent and monotonic via `authResolvedRef`: the first call wins and the
|
|
1044
|
-
// setters fire at most once, so the restore `finally` backstop becomes a no-op
|
|
1045
|
-
// once a commit site has already marked resolution. Called from EVERY place a
|
|
1046
|
-
// user is actually committed (the iframe/SSO path
|
|
1047
|
-
// `handleWebSSOSession` and the stored-session path) so the common reload
|
|
1048
|
-
// case unblocks the loading gate without sitting behind the remaining
|
|
1049
|
-
// (now-skipped) cold-boot steps.
|
|
1050
|
-
const markAuthResolved = useCallback(() => {
|
|
1051
|
-
if (authResolvedRef.current) {
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
authResolvedRef.current = true;
|
|
1055
|
-
setTokenReady(true);
|
|
1056
|
-
setAuthResolved(true);
|
|
1057
|
-
}, []);
|
|
1058
|
-
const markAuthResolvedRef = useRef(markAuthResolved);
|
|
1059
|
-
markAuthResolvedRef.current = markAuthResolved;
|
|
1060
|
-
|
|
1061
|
-
// `handleWebSSOSession` is declared further down (it depends on values that
|
|
1062
|
-
// are only available there). The iframe/SSO cold-boot steps need to commit
|
|
1063
|
-
// a recovered session through it, so we route the call through a ref that is
|
|
1064
|
-
// populated once the callback exists. The ref is assigned synchronously on
|
|
1065
|
-
// every render before the cold-boot effect can fire (the effect is gated on
|
|
1066
|
-
// `storage` + `initialized`, both of which settle after first render).
|
|
1067
|
-
const handleWebSSOSessionRef = useRef<((session: SessionLoginResponse) => Promise<void>) | null>(null);
|
|
1068
|
-
|
|
1069
|
-
// Native (and offline) stored-session restore — the ONLY restore path that
|
|
1070
|
-
// runs on React Native, and the web fallback when no cross-domain step won.
|
|
1071
|
-
//
|
|
1072
|
-
// Stored-session restore. Web uses this only as a fast local winner after
|
|
1073
|
-
// URL-return handling; native uses it as the durable SecureStore path. Native
|
|
1074
|
-
// first plants the shared access token from KeyManager, then validates the
|
|
1075
|
-
// stored session ids with the bearer already in memory.
|
|
1076
|
-
const restoreStoredSession = useCallback(async (): Promise<boolean> => {
|
|
1077
|
-
if (!storage) {
|
|
1078
|
-
return false;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
|
|
1082
|
-
const storedSessionIdsFromStorage: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
|
|
1083
|
-
let storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
|
|
1084
|
-
// OPTIONAL HINT ONLY (no longer a gate). Nothing writes the active-`authuser`
|
|
1085
|
-
// marker since the SessionClient cutover (`writeActiveAuthuser` was deleted),
|
|
1086
|
-
// so it is `null` on every fresh install; when a legacy value is present it
|
|
1087
|
-
// is used purely to backfill `clientSession.authuser` for the active session
|
|
1088
|
-
// below. It MUST NOT decide whether restore is attempted — see the guard.
|
|
1089
|
-
const storedActiveAuthuser = isWebBrowser() ? readActiveAuthuser() : null;
|
|
1090
|
-
|
|
1091
|
-
// On web with no in-memory bearer, bail ONLY when there is genuinely nothing
|
|
1092
|
-
// to restore — neither a stored active id NOR any stored session ids. A
|
|
1093
|
-
// surviving `sessionIds` list (e.g. the active-id key was removed after a
|
|
1094
|
-
// server-side revocation while sibling sessions remain) MUST still be
|
|
1095
|
-
// validated over the network and have a valid session elected as active
|
|
1096
|
-
// below. The old guard also required the deleted `activeAuthuser` marker,
|
|
1097
|
-
// which since the cutover is never written — that made EVERY web reload
|
|
1098
|
-
// whose local bearer had lapsed bail before any validation. Native always
|
|
1099
|
-
// proceeds (it plants the shared KeyManager bearer next).
|
|
1100
|
-
if (
|
|
1101
|
-
isWebBrowser() &&
|
|
1102
|
-
!oxyServices.getAccessToken() &&
|
|
1103
|
-
storedActiveSessionId === null &&
|
|
1104
|
-
storedSessionIdsFromStorage.length === 0
|
|
1105
|
-
) {
|
|
1106
|
-
return false;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
const nativeSharedSession = !isWebBrowser()
|
|
1110
|
-
? await KeyManager.getSharedSession().catch(() => null)
|
|
1111
|
-
: null;
|
|
1112
|
-
if (nativeSharedSession?.accessToken) {
|
|
1113
|
-
oxyServices.setTokens(nativeSharedSession.accessToken);
|
|
1114
|
-
storedActiveSessionId = storedActiveSessionId ?? nativeSharedSession.sessionId;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
const storedSessionIds = Array.from(new Set([
|
|
1118
|
-
...storedSessionIdsFromStorage,
|
|
1119
|
-
...(nativeSharedSession?.sessionId ? [nativeSharedSession.sessionId] : []),
|
|
1120
|
-
]));
|
|
1121
|
-
|
|
1122
|
-
let validSessions: ClientSession[] = [];
|
|
1123
|
-
let unvalidatedSessionIds: string[] = [];
|
|
1124
|
-
|
|
1125
|
-
if (storedSessionIds.length > 0) {
|
|
1126
|
-
// Validate all sessions in parallel (with a per-session timeout) to avoid
|
|
1127
|
-
// sequential blocking that freezes the app on startup
|
|
1128
|
-
const results = await Promise.allSettled(
|
|
1129
|
-
storedSessionIds.map(async (sessionId) => {
|
|
1130
|
-
const timeoutPromise = new Promise<typeof VALIDATION_TIMEOUT_RESULT>((resolve) =>
|
|
1131
|
-
setTimeout(() => resolve(VALIDATION_TIMEOUT_RESULT), VALIDATION_TIMEOUT),
|
|
1132
|
-
);
|
|
1133
|
-
const validationPromise = oxyServices
|
|
1134
|
-
.validateSession(sessionId, { useHeaderValidation: true })
|
|
1135
|
-
.catch((validationError: unknown) => {
|
|
1136
|
-
if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
|
|
1137
|
-
logger('Session validation failed during init', validationError);
|
|
1138
|
-
} else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
|
|
1139
|
-
loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, validationError as unknown);
|
|
1140
|
-
}
|
|
1141
|
-
return null;
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
return Promise.race([validationPromise, timeoutPromise]).then((validation) => {
|
|
1145
|
-
if (validation === VALIDATION_TIMEOUT_RESULT) {
|
|
1146
|
-
return { sessionId, timedOut: true as const };
|
|
1147
|
-
}
|
|
1148
|
-
if (validation?.valid && validation.user) {
|
|
1149
|
-
const now = new Date();
|
|
1150
|
-
const clientSession: ClientSession = {
|
|
1151
|
-
sessionId,
|
|
1152
|
-
deviceId: '',
|
|
1153
|
-
expiresAt: new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
|
|
1154
|
-
lastActive: now.toISOString(),
|
|
1155
|
-
userId: validation.user.id?.toString() ?? '',
|
|
1156
|
-
isCurrent: sessionId === storedActiveSessionId,
|
|
1157
|
-
};
|
|
1158
|
-
if (isWebBrowser() && sessionId === storedActiveSessionId && storedActiveAuthuser !== null) {
|
|
1159
|
-
clientSession.authuser = storedActiveAuthuser;
|
|
1160
|
-
}
|
|
1161
|
-
return { session: clientSession, timedOut: false as const };
|
|
1162
|
-
}
|
|
1163
|
-
return { session: null, timedOut: false as const };
|
|
1164
|
-
});
|
|
1165
|
-
}),
|
|
1166
|
-
);
|
|
681
|
+
}, [oxyServices, authStore]);
|
|
682
|
+
|
|
683
|
+
// ── Session commit funnel ────────────────────────────────────────────────
|
|
684
|
+
// The single place a session becomes committed: plant tokens, persist the
|
|
685
|
+
// rotating refresh family, register the account into the server-authoritative
|
|
686
|
+
// device set, and hydrate the full user. Used by the QR device flow
|
|
687
|
+
// (`handleWebSession`), password sign-in, 2FA completion, and the cold boot.
|
|
688
|
+
const commitSession = useCallback(
|
|
689
|
+
async (input: CommitInput, options: { activate: boolean }): Promise<void> => {
|
|
690
|
+
if (input.accessToken) {
|
|
691
|
+
oxyServices.setTokens(input.accessToken);
|
|
692
|
+
}
|
|
1167
693
|
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
694
|
+
// Persist the durable blob when a rotating refresh token is present
|
|
695
|
+
// (trusted-lane sign-ins). The cold boot has usually persisted already;
|
|
696
|
+
// this is idempotent. A missing refresh token means the session survives
|
|
697
|
+
// reload via the device cookie (web) / shared keychain (native) instead.
|
|
698
|
+
if (input.refreshToken && input.userId) {
|
|
699
|
+
try {
|
|
700
|
+
const deviceToken = (await authStore.loadDeviceToken()) ?? undefined;
|
|
701
|
+
const next: PersistedAuthState = {
|
|
702
|
+
sessionId: input.sessionId,
|
|
703
|
+
refreshToken: input.refreshToken,
|
|
704
|
+
userId: input.userId,
|
|
705
|
+
...(deviceToken ? { deviceToken } : {}),
|
|
706
|
+
...(input.accessToken ? { accessToken: input.accessToken } : {}),
|
|
707
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
708
|
+
};
|
|
709
|
+
await authStore.save(next);
|
|
710
|
+
} catch (persistError) {
|
|
711
|
+
logger('Failed to persist auth state on commit', persistError);
|
|
1175
712
|
}
|
|
1176
713
|
}
|
|
1177
714
|
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
715
|
+
// Fast local mirror so the UI updates before the server round-trip; the
|
|
716
|
+
// SessionClient projection below overwrites it with server truth.
|
|
717
|
+
if (input.userId) {
|
|
718
|
+
const now = new Date();
|
|
719
|
+
updateSessions(
|
|
720
|
+
[
|
|
721
|
+
{
|
|
722
|
+
sessionId: input.sessionId,
|
|
723
|
+
deviceId: input.deviceId ?? '',
|
|
724
|
+
expiresAt: input.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
|
|
725
|
+
lastActive: now.toISOString(),
|
|
726
|
+
userId: input.userId,
|
|
727
|
+
isCurrent: true,
|
|
728
|
+
},
|
|
729
|
+
],
|
|
730
|
+
{ merge: true },
|
|
731
|
+
);
|
|
732
|
+
setActiveSessionId(input.sessionId);
|
|
733
|
+
}
|
|
1187
734
|
|
|
1188
|
-
|
|
735
|
+
// Register into the device set. A deliberate sign-in ACTIVATES the account
|
|
736
|
+
// (`registerAndActivate`); the cold boot only ensures membership and lets
|
|
737
|
+
// the server's own `activeAccountId` win (`addCurrentAccount`). Both then
|
|
738
|
+
// start the socket + project. Best-effort — a failure never fails the
|
|
739
|
+
// sign-in (cold boot re-registers on the next load).
|
|
1189
740
|
try {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
// and the common web reload winner). Unblock the auth-resolution gate
|
|
1193
|
-
// immediately so the loading screen clears without waiting for the
|
|
1194
|
-
// remaining cold-boot steps to be evaluated/short-circuited (idempotent).
|
|
1195
|
-
markAuthResolvedRef.current();
|
|
1196
|
-
// Backfill the returning-user hint. A reload winner already had the hint
|
|
1197
|
-
// set at original sign-in, but pre-existing installs (signed in before
|
|
1198
|
-
// this hint shipped) get it set here so their NEXT lapse-and-return still
|
|
1199
|
-
// earns one `/sso` establish bounce. `storage` is non-null (guarded at
|
|
1200
|
-
// the top of this callback); best-effort, never blocks restore.
|
|
1201
|
-
await storage.setItem(storageKeys.priorSession, '1');
|
|
1202
|
-
return true;
|
|
1203
|
-
} catch (switchError) {
|
|
1204
|
-
// Silently handle expected errors (invalid sessions, timeouts, network issues)
|
|
1205
|
-
if (isInvalidSessionError(switchError)) {
|
|
1206
|
-
// The stored active id was rejected server-side. Clear it and drop the
|
|
1207
|
-
// dead session from the local view, then FALL THROUGH to the election
|
|
1208
|
-
// below to promote a surviving valid session — a stale/removed active
|
|
1209
|
-
// marker must not strand the other still-valid accounts.
|
|
1210
|
-
await storage.removeItem(storageKeys.activeSessionId);
|
|
1211
|
-
validSessions = validSessions.filter((session) => session.sessionId !== storedActiveSessionId);
|
|
1212
|
-
updateSessionsRef.current(validSessions, {
|
|
1213
|
-
merge: false,
|
|
1214
|
-
preserveSessionIds: unvalidatedSessionIds,
|
|
1215
|
-
});
|
|
1216
|
-
// Don't log expected session errors during restoration
|
|
1217
|
-
} else if (isTimeoutOrNetworkError(switchError)) {
|
|
1218
|
-
// Timeout/network — the stored active session may still be valid; we
|
|
1219
|
-
// just could not confirm it. Do NOT elect a different session (that
|
|
1220
|
-
// could switch accounts under the user); leave restore to a retry / the
|
|
1221
|
-
// remaining cold-boot steps.
|
|
1222
|
-
if (__DEV__) {
|
|
1223
|
-
loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, switchError as unknown);
|
|
1224
|
-
}
|
|
1225
|
-
return false;
|
|
741
|
+
if (options.activate) {
|
|
742
|
+
await sessionClient.registerAndActivate(input.userId);
|
|
1226
743
|
} else {
|
|
1227
|
-
|
|
1228
|
-
logger('Active session validation error', switchError);
|
|
1229
|
-
return false;
|
|
744
|
+
await sessionClient.addCurrentAccount();
|
|
1230
745
|
}
|
|
746
|
+
await sessionClient.start();
|
|
747
|
+
await syncFromClient();
|
|
748
|
+
} catch (registrationError) {
|
|
749
|
+
loggerUtil.warn(
|
|
750
|
+
'commitSession: device-set registration failed',
|
|
751
|
+
{ component: 'OxyContext', method: 'commitSession' },
|
|
752
|
+
registrationError as unknown,
|
|
753
|
+
);
|
|
1231
754
|
}
|
|
1232
|
-
}
|
|
1233
755
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
// Promote the FIRST validated session to active and commit it through the
|
|
1238
|
-
// same `switchSession` path. The server owns which account is truly active,
|
|
1239
|
-
// so any valid session is a safe provisional active — the post-restore
|
|
1240
|
-
// SessionClient handoff reconciles the real active account immediately after.
|
|
1241
|
-
const electedSession = validSessions[0];
|
|
1242
|
-
if (electedSession) {
|
|
756
|
+
// Hydrate the full user (the commit input carries only minimal data). Fall
|
|
757
|
+
// back to the minimal shape if the profile fetch fails.
|
|
758
|
+
let fullUser: User | null = null;
|
|
1243
759
|
try {
|
|
1244
|
-
await
|
|
1245
|
-
|
|
1246
|
-
await storage.setItem(storageKeys.priorSession, '1');
|
|
1247
|
-
markAuthResolvedRef.current();
|
|
1248
|
-
return true;
|
|
1249
|
-
} catch (electionError) {
|
|
1250
|
-
// Activation requires an in-memory bearer on web; without one (a plain
|
|
1251
|
-
// web reload) the switch throws and recovery defers to the cross-domain
|
|
1252
|
-
// cold-boot steps. Invalid/timeout/network are expected here — surface
|
|
1253
|
-
// only genuinely unexpected failures.
|
|
1254
|
-
if (!isInvalidSessionError(electionError) && !isTimeoutOrNetworkError(electionError)) {
|
|
1255
|
-
logger('Elected session activation error', electionError);
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
return false;
|
|
1261
|
-
}, [
|
|
1262
|
-
logger,
|
|
1263
|
-
oxyServices,
|
|
1264
|
-
storage,
|
|
1265
|
-
storageKeys.activeSessionId,
|
|
1266
|
-
storageKeys.sessionIds,
|
|
1267
|
-
storageKeys.priorSession,
|
|
1268
|
-
]);
|
|
1269
|
-
|
|
1270
|
-
// Shared in-flight `runSsoReturn` promise — see the CONCURRENCY note on
|
|
1271
|
-
// `runSsoReturn` below. Lets the eager interception effect and the cold-boot
|
|
1272
|
-
// `sso-return` step share one `consumeSsoReturn` invocation race-free.
|
|
1273
|
-
const inFlightSsoReturnRef = useRef<Promise<boolean> | null>(null);
|
|
1274
|
-
|
|
1275
|
-
// Central cross-domain SSO return handler (web). A THIN wrapper over core's
|
|
1276
|
-
// `consumeSsoReturn`, which performs the entire security-critical kernel —
|
|
1277
|
-
// parse the IdP redirect fragment, validate the CSRF `state`, strip the
|
|
1278
|
-
// fragment FIRST, exchange the opaque single-use code, restore the user's
|
|
1279
|
-
// pre-bounce destination (same-origin only), and set the per-origin
|
|
1280
|
-
// NO_SESSION loop breaker on every non-ok outcome — and RETURNS the exchanged
|
|
1281
|
-
// session (or `null`) WITHOUT committing. We preserve services' contract by
|
|
1282
|
-
// committing the returned session here via `handleWebSSOSession`. Shared by
|
|
1283
|
-
// the `sso-return` cold-boot step AND the bfcache `pageshow` re-evaluation, so
|
|
1284
|
-
// the same kernel runs exactly once per delivered fragment regardless of how
|
|
1285
|
-
// the page was (re)shown.
|
|
1286
|
-
//
|
|
1287
|
-
// Returns `true` when a session was committed (caller short-circuits), `false`
|
|
1288
|
-
// otherwise. Off-browser `consumeSsoReturn` is a no-op returning `null`, so
|
|
1289
|
-
// this returns `false` (native never reaches it).
|
|
1290
|
-
//
|
|
1291
|
-
// CONCURRENCY: the eager SSO-callback interception effect and the cold-boot
|
|
1292
|
-
// `sso-return` step can both invoke this in the same tick when we land on the
|
|
1293
|
-
// callback path. `consumeSsoReturn` strips the fragment FIRST, so a naive
|
|
1294
|
-
// second invocation would parse an already-stripped URL and return `null` —
|
|
1295
|
-
// leaving the losing caller with no session (and on an `ok` outcome could let
|
|
1296
|
-
// the terminal bounce fire spuriously). The FIRST call's promise is memoised in
|
|
1297
|
-
// `inFlightSsoReturnRef` and SHARED with every concurrent caller, so the single
|
|
1298
|
-
// `consumeSsoReturn` invocation — and its single commit via `handleWebSSOSession`
|
|
1299
|
-
// — is delivered identically to both paths. Cleared once it settles so a later,
|
|
1300
|
-
// genuinely-separate return (e.g. a bfcache restore) runs a fresh pass.
|
|
1301
|
-
const runSsoReturn = useCallback((): Promise<boolean> => {
|
|
1302
|
-
if (inFlightSsoReturnRef.current) {
|
|
1303
|
-
return inFlightSsoReturnRef.current;
|
|
1304
|
-
}
|
|
1305
|
-
const inFlight = consumeSsoReturn(oxyServices, {
|
|
1306
|
-
isWeb: isWebBrowser,
|
|
1307
|
-
onExchangeError: (error) => {
|
|
760
|
+
fullUser = await oxyServices.getCurrentUser();
|
|
761
|
+
} catch (profileError) {
|
|
1308
762
|
if (__DEV__) {
|
|
1309
763
|
loggerUtil.debug(
|
|
1310
|
-
'
|
|
1311
|
-
{ component: 'OxyContext', method: '
|
|
1312
|
-
|
|
764
|
+
'Failed to fetch full user on commit; using minimal fallback',
|
|
765
|
+
{ component: 'OxyContext', method: 'commitSession' },
|
|
766
|
+
profileError as unknown,
|
|
1313
767
|
);
|
|
1314
768
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
// its fast/silent failure mode combined with a caller's auth-guard effect
|
|
1353
|
-
// could re-trigger sign-in in a tight loop; see `CrossDomainAuth`'s doc
|
|
1354
|
-
// comment in `@oxyhq/core`).
|
|
1355
|
-
//
|
|
1356
|
-
// LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect
|
|
1357
|
-
// `silent-iframe` probe. On a normal reload the local bearer validates in
|
|
1358
|
-
// one round-trip and wins, so `runColdBoot` short-circuits and never sits
|
|
1359
|
-
// through that probe's timeout. `sso-return` MUST stay first — it consumes
|
|
1360
|
-
// the URL fragment before anything can strip it. On a first visit with no
|
|
1361
|
-
// local session, `stored-session` skips and the cross-domain fallback chain
|
|
1362
|
-
// (silent-iframe → sso-bounce) runs exactly as before; the per-apex silent
|
|
1363
|
-
// iframe still restores a durable cross-domain session on reload WITHOUT a
|
|
1364
|
-
// top-level bounce, so when it wins `sso-bounce` never fires (no flash, no
|
|
1365
|
-
// loop).
|
|
1366
|
-
// Order (native): stored session only (every web-only step is disabled
|
|
1367
|
-
// off-browser).
|
|
1368
|
-
const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
|
|
1369
|
-
if (!storage) {
|
|
1370
|
-
return;
|
|
1371
|
-
}
|
|
769
|
+
fullUser = (input.user as unknown as User) ?? null;
|
|
770
|
+
}
|
|
771
|
+
if (fullUser) {
|
|
772
|
+
loginSuccess(fullUser);
|
|
773
|
+
onAuthStateChangeRef.current?.(fullUser);
|
|
774
|
+
}
|
|
775
|
+
markAuthResolvedRef.current();
|
|
776
|
+
},
|
|
777
|
+
[oxyServices, authStore, updateSessions, setActiveSessionId, sessionClient, syncFromClient, loginSuccess, logger],
|
|
778
|
+
);
|
|
779
|
+
const commitSessionRef = useRef(commitSession);
|
|
780
|
+
commitSessionRef.current = commitSession;
|
|
781
|
+
|
|
782
|
+
// Public `handleWebSession`: commit a session from the QR device flow. It is a
|
|
783
|
+
// deliberate sign-in on THIS device, so it activates the account.
|
|
784
|
+
const handleWebSession = useCallback(
|
|
785
|
+
async (session: SessionLoginResponse): Promise<void> => {
|
|
786
|
+
if (!session?.user || !session?.sessionId || !session.accessToken) {
|
|
787
|
+
throw new Error('Session response did not include a usable session');
|
|
788
|
+
}
|
|
789
|
+
await commitSession(
|
|
790
|
+
{
|
|
791
|
+
sessionId: session.sessionId,
|
|
792
|
+
accessToken: session.accessToken,
|
|
793
|
+
// deviceFlow threads a rotating refresh token on the runtime object
|
|
794
|
+
// even though `SessionLoginResponse` does not type it.
|
|
795
|
+
refreshToken: (session as { refreshToken?: string }).refreshToken,
|
|
796
|
+
deviceId: session.deviceId,
|
|
797
|
+
expiresAt: session.expiresAt,
|
|
798
|
+
userId: session.user.id,
|
|
799
|
+
user: session.user,
|
|
800
|
+
},
|
|
801
|
+
{ activate: true },
|
|
802
|
+
);
|
|
803
|
+
},
|
|
804
|
+
[commitSession],
|
|
805
|
+
);
|
|
1372
806
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
// FIX-B smart-gate input: has this device/app EVER had a signed-in Oxy
|
|
1390
|
-
// session (the durable `priorSession` hint, set on every commit, cleared
|
|
1391
|
-
// only on explicit full sign-out)? Read ONCE here — synchronously usable by
|
|
1392
|
-
// the terminal `sso-bounce` `enabled` gate below — so a RETURNING visitor
|
|
1393
|
-
// whose local session has lapsed still earns one `/sso` establish bounce,
|
|
1394
|
-
// while a truly first-time anonymous visitor is never force-redirected.
|
|
1395
|
-
// `storage` is non-null (guarded above); a read failure is treated as "no
|
|
1396
|
-
// prior session" (fail safe toward anonymous-browse).
|
|
1397
|
-
let hadPriorSession = false;
|
|
1398
|
-
try {
|
|
1399
|
-
hadPriorSession = (await storage.getItem(storageKeys.priorSession)) === '1';
|
|
1400
|
-
} catch (priorSessionReadError) {
|
|
1401
|
-
if (__DEV__) {
|
|
1402
|
-
loggerUtil.debug(
|
|
1403
|
-
'Failed to read prior-session hint (treating as first-time visitor)',
|
|
1404
|
-
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1405
|
-
priorSessionReadError as unknown,
|
|
1406
|
-
);
|
|
807
|
+
// Keyless password sign-in: username/email + password.
|
|
808
|
+
const signInWithPassword = useCallback(
|
|
809
|
+
async (
|
|
810
|
+
identifier: string,
|
|
811
|
+
password: string,
|
|
812
|
+
opts?: { deviceName?: string; deviceFingerprint?: string },
|
|
813
|
+
): Promise<PasswordSignInResult> => {
|
|
814
|
+
const deviceToken = (await authStore.loadDeviceToken()) ?? undefined;
|
|
815
|
+
const result = await oxyServices.passwordSignIn(identifier, password, {
|
|
816
|
+
deviceToken,
|
|
817
|
+
deviceName: opts?.deviceName,
|
|
818
|
+
deviceFingerprint: opts?.deviceFingerprint,
|
|
819
|
+
});
|
|
820
|
+
if ('twoFactorRequired' in result) {
|
|
821
|
+
return { status: '2fa_required', loginToken: result.loginToken };
|
|
1407
822
|
}
|
|
1408
|
-
|
|
823
|
+
// `passwordSignIn` already planted the access token on the session arm.
|
|
824
|
+
await commitSession(
|
|
825
|
+
{
|
|
826
|
+
sessionId: result.sessionId,
|
|
827
|
+
accessToken: result.accessToken,
|
|
828
|
+
refreshToken: result.refreshToken,
|
|
829
|
+
deviceId: result.deviceId,
|
|
830
|
+
expiresAt: result.expiresAt,
|
|
831
|
+
userId: result.user.id,
|
|
832
|
+
user: result.user,
|
|
833
|
+
},
|
|
834
|
+
{ activate: true },
|
|
835
|
+
);
|
|
836
|
+
return { status: 'ok' };
|
|
837
|
+
},
|
|
838
|
+
[oxyServices, authStore, commitSession],
|
|
839
|
+
);
|
|
1409
840
|
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
841
|
+
const completeTwoFactorSignIn = useCallback(
|
|
842
|
+
async (params: {
|
|
843
|
+
loginToken: string;
|
|
844
|
+
token?: string;
|
|
845
|
+
backupCode?: string;
|
|
846
|
+
deviceName?: string;
|
|
847
|
+
}): Promise<void> => {
|
|
848
|
+
const deviceToken = (await authStore.loadDeviceToken()) ?? undefined;
|
|
849
|
+
const result = await oxyServices.completeTwoFactorSignIn({
|
|
850
|
+
loginToken: params.loginToken,
|
|
851
|
+
token: params.token,
|
|
852
|
+
backupCode: params.backupCode,
|
|
853
|
+
deviceToken,
|
|
854
|
+
deviceName: params.deviceName,
|
|
855
|
+
});
|
|
856
|
+
await commitSession(
|
|
857
|
+
{
|
|
858
|
+
sessionId: result.sessionId,
|
|
859
|
+
accessToken: result.accessToken,
|
|
860
|
+
refreshToken: result.refreshToken,
|
|
861
|
+
deviceId: result.deviceId,
|
|
862
|
+
expiresAt: result.expiresAt,
|
|
863
|
+
userId: result.user.id,
|
|
864
|
+
user: result.user,
|
|
865
|
+
},
|
|
866
|
+
{ activate: true },
|
|
867
|
+
);
|
|
868
|
+
},
|
|
869
|
+
[oxyServices, authStore, commitSession],
|
|
870
|
+
);
|
|
1421
871
|
|
|
872
|
+
// ── Cold boot ────────────────────────────────────────────────────────────
|
|
873
|
+
// The single device-first session restore. Runs ONCE after storage init.
|
|
874
|
+
// `runSessionColdBoot` resolves the device's session (bootstrap-return →
|
|
875
|
+
// stored-tokens → shared-key (native) → bootstrap-hop (web)) WITHOUT ever
|
|
876
|
+
// redirecting to a login page; an unresolved boot ends signed-out and the app
|
|
877
|
+
// renders its "Sign in with Oxy" affordance. On a winning boot the token is
|
|
878
|
+
// already planted + persisted, so `onSession` only hands off to the
|
|
879
|
+
// SessionClient (membership + active account + realtime socket) and marks
|
|
880
|
+
// auth resolved. `onSignedOut` marks auth resolved with no session.
|
|
881
|
+
const runColdBoot = useCallback(async (): Promise<void> => {
|
|
882
|
+
setTokenReady(false);
|
|
1422
883
|
try {
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
// anything else) strips it. On a first visit with no local
|
|
1452
|
-
// session this step skips and the cross-domain fallback chain
|
|
1453
|
-
// (silent-iframe → sso-bounce) runs exactly as before.
|
|
1454
|
-
id: 'stored-session',
|
|
1455
|
-
run: async () => {
|
|
1456
|
-
const restored = await restoreStoredSession();
|
|
1457
|
-
if (restored) {
|
|
1458
|
-
// FIX-B: record the win so the slow probes below explicitly skip
|
|
1459
|
-
// (belt-and-suspenders; `runColdBoot` already short-circuits).
|
|
1460
|
-
storedSessionRestored = true;
|
|
1461
|
-
return { kind: 'session', session: true };
|
|
1462
|
-
}
|
|
1463
|
-
return { kind: 'skip' };
|
|
1464
|
-
},
|
|
1465
|
-
},
|
|
1466
|
-
{
|
|
1467
|
-
// 2.5) Shared-key SSO (NATIVE ONLY) — same-device, no interaction.
|
|
1468
|
-
//
|
|
1469
|
-
// When a sibling Oxy app (the Commons identity vault) has written
|
|
1470
|
-
// the cross-app shared identity into the `group.so.oxy.shared`
|
|
1471
|
-
// keychain, this mints THIS app's session from it silently:
|
|
1472
|
-
// `signInWithSharedIdentity()` proves control of the shared key
|
|
1473
|
-
// (challenge → sign → verify, which plants the tokens server-side)
|
|
1474
|
-
// and returns a `SessionLoginResponse`, which we commit through the
|
|
1475
|
-
// SAME path the web SSO steps use (`handleWebSSOSession`), so state,
|
|
1476
|
-
// durable persistence, profile fetch, and `markAuthResolved` all run
|
|
1477
|
-
// identically. Returns `null` when no shared identity is present, so
|
|
1478
|
-
// a device without Commons simply falls through.
|
|
1479
|
-
//
|
|
1480
|
-
// ORDERING: runs immediately AFTER `stored-session` (an already
|
|
1481
|
-
// restored local bearer always wins first — `storedSessionRestored`
|
|
1482
|
-
// gates this off) and BEFORE the web-only probes (which are gated
|
|
1483
|
-
// off on native anyway). Native-only: `enabled` requires
|
|
1484
|
-
// `!isWebBrowser()`, and the core method also returns `null` on web,
|
|
1485
|
-
// so `WebOxyProvider` / the web path are entirely unaffected.
|
|
1486
|
-
// Bounded by `SHARED_KEY_SIGNIN_TIMEOUT` so a slow/offline network
|
|
1487
|
-
// cannot stall cold boot.
|
|
1488
|
-
id: 'shared-key-signin',
|
|
1489
|
-
enabled: () => !isWebBrowser() && !storedSessionRestored,
|
|
1490
|
-
run: async () => {
|
|
1491
|
-
if (!commitWebSession) {
|
|
1492
|
-
return { kind: 'skip' };
|
|
1493
|
-
}
|
|
1494
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1495
|
-
const session = await Promise.race<SessionLoginResponse | null>([
|
|
1496
|
-
oxyServices.signInWithSharedIdentity?.() ?? Promise.resolve(null),
|
|
1497
|
-
new Promise<null>((resolve) => {
|
|
1498
|
-
timeoutId = setTimeout(() => resolve(null), SHARED_KEY_SIGNIN_TIMEOUT);
|
|
1499
|
-
}),
|
|
1500
|
-
]).finally(() => {
|
|
1501
|
-
if (timeoutId !== undefined) {
|
|
1502
|
-
clearTimeout(timeoutId);
|
|
1503
|
-
}
|
|
1504
|
-
});
|
|
1505
|
-
if (!session) {
|
|
1506
|
-
return { kind: 'skip' };
|
|
1507
|
-
}
|
|
1508
|
-
await commitWebSession(session);
|
|
1509
|
-
// Record the win so the (web-only) probes below explicitly skip —
|
|
1510
|
-
// belt-and-suspenders; `runColdBoot` already short-circuits on the
|
|
1511
|
-
// first `{kind:'session'}`, and those probes are off on native.
|
|
1512
|
-
storedSessionRestored = true;
|
|
1513
|
-
return { kind: 'session', session: true };
|
|
1514
|
-
},
|
|
1515
|
-
},
|
|
1516
|
-
{
|
|
1517
|
-
// 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
|
|
1518
|
-
// cross-domain reload-restore path. The durable session lives as a
|
|
1519
|
-
// first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
|
|
1520
|
-
// `auth.mention.earth`), established during the `/sso` bounce's
|
|
1521
|
-
// `/sso/establish` hop. That host is SAME-SITE to the RP page, so
|
|
1522
|
-
// the cookie is first-party under Safari ITP / Firefox TCP — and
|
|
1523
|
-
// an iframe read is NOT a top-level navigation, so it restores on
|
|
1524
|
-
// reload with NO flash and works in a backgrounded tab. This is the
|
|
1525
|
-
// step that prevents the re-bounce loop: when it finds a session,
|
|
1526
|
-
// the terminal `sso-bounce` never fires.
|
|
1527
|
-
//
|
|
1528
|
-
// The per-apex iframe mint itself lives in
|
|
1529
|
-
// `mintSessionViaPerApexIframe` (shared verbatim with the in-session
|
|
1530
|
-
// refresh handler so the two paths can never drift): it points the
|
|
1531
|
-
// iframe at `autoDetectAuthWebUrl()` and skips when there is no
|
|
1532
|
-
// per-apex IdP (localhost/IP/single-label/off-browser). Web only; on
|
|
1533
|
-
// native `isWebBrowser()` gates it off, so native never runs an
|
|
1534
|
-
// iframe.
|
|
1535
|
-
//
|
|
1536
|
-
// FIX-B: additionally skipped when `stored-session` already won.
|
|
1537
|
-
// FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
|
|
1538
|
-
// fail-fast in core) so a no-message iframe cannot stall cold boot.
|
|
1539
|
-
id: 'silent-iframe',
|
|
1540
|
-
enabled: () => !storedSessionRestored && !silentRestoreBlocked && isWebBrowser(),
|
|
1541
|
-
run: async () => {
|
|
1542
|
-
if (!commitWebSession) {
|
|
1543
|
-
return { kind: 'skip' };
|
|
1544
|
-
}
|
|
1545
|
-
// Shared with the in-session refresh handler: the ONE
|
|
1546
|
-
// implementation of "mint a first-party per-apex token without a
|
|
1547
|
-
// reload" lives in `silentSessionRestore` so cold boot and refresh
|
|
1548
|
-
// never drift.
|
|
1549
|
-
const session = await mintSessionViaPerApexIframe(oxyServices, SILENT_IFRAME_TIMEOUT);
|
|
1550
|
-
if (!session) {
|
|
1551
|
-
return { kind: 'skip' };
|
|
1552
|
-
}
|
|
1553
|
-
await commitWebSession(session);
|
|
1554
|
-
return { kind: 'session', session: true };
|
|
1555
|
-
},
|
|
1556
|
-
},
|
|
1557
|
-
{
|
|
1558
|
-
// 4) SSO bounce (TERMINAL, web only, at most once). No local session
|
|
1559
|
-
// was found by any step above. Top-level navigate to the central
|
|
1560
|
-
// `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
|
|
1561
|
-
// (returning an opaque code we exchange on the callback) or report
|
|
1562
|
-
// `none`. This step tears the document down on success — its `skip`
|
|
1563
|
-
// result is only observed if `assign` no-ops. Disabled on the IdP
|
|
1564
|
-
// itself, once the NO_SESSION flag is set, or while a bounce guard is
|
|
1565
|
-
// still active (loop + self-heal protection).
|
|
1566
|
-
id: 'sso-bounce',
|
|
1567
|
-
enabled: () => {
|
|
1568
|
-
// Smart gate (SDK-owned, shared with `WebOxyProvider` via core's
|
|
1569
|
-
// `allowSsoBounce`). The terminal `/sso` establish-bounce is the
|
|
1570
|
-
// ONLY cold-boot step that can recover a session living SOLELY at
|
|
1571
|
-
// the central IdP (a cross-apex RP whose local session expired),
|
|
1572
|
-
// and it is what plants the per-apex `fedcm_session` cookie the
|
|
1573
|
-
// earlier silent-iframe step relies on. So it is allowed iff a
|
|
1574
|
-
// prior-signed-in hint exists OR a local session was recovered this
|
|
1575
|
-
// boot (`storedSessionRestored`, always false here — an earlier step
|
|
1576
|
-
// would have won — but passed for spec fidelity): a RETURNING user
|
|
1577
|
-
// still gets ONE bounce, while a truly first-time anonymous visitor
|
|
1578
|
-
// does NOT (anonymous browse). The per-tab loop guards below
|
|
1579
|
-
// (`ssoNoSessionKey`, `ssoAttemptedKey`, `guardActive`) still cap an
|
|
1580
|
-
// allowed bounce at one per cold boot.
|
|
1581
|
-
if (!allowSsoBounce({
|
|
1582
|
-
hasPriorSession: hadPriorSession,
|
|
1583
|
-
hasLocalSession: storedSessionRestored,
|
|
1584
|
-
})) {
|
|
1585
|
-
return false;
|
|
1586
|
-
}
|
|
1587
|
-
if (!isWebBrowser() || window.top !== window.self) {
|
|
1588
|
-
return false;
|
|
1589
|
-
}
|
|
1590
|
-
const origin = window.location.origin;
|
|
1591
|
-
if (isCentralIdPOrigin(origin)) {
|
|
1592
|
-
return false;
|
|
1593
|
-
}
|
|
1594
|
-
if (window.sessionStorage.getItem(ssoNoSessionKey(origin)) === '1') {
|
|
1595
|
-
return false;
|
|
1596
|
-
}
|
|
1597
|
-
if (window.sessionStorage.getItem(ssoAttemptedKey(origin)) === '1') {
|
|
1598
|
-
return false;
|
|
1599
|
-
}
|
|
1600
|
-
if (guardActive(window.sessionStorage, origin, Date.now())) {
|
|
1601
|
-
return false;
|
|
1602
|
-
}
|
|
1603
|
-
return true;
|
|
1604
|
-
},
|
|
1605
|
-
run: async () => {
|
|
1606
|
-
const origin = window.location.origin;
|
|
1607
|
-
const state = oxyServices.generateSsoState();
|
|
1608
|
-
window.sessionStorage.setItem(ssoStateKey(origin), state);
|
|
1609
|
-
window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
|
|
1610
|
-
window.sessionStorage.setItem(ssoDestKey(origin), window.location.href);
|
|
1611
|
-
// OUTCOME-INDEPENDENT once-guard: mark the probe attempted the instant we
|
|
1612
|
-
// commit to the bounce, so even if the callback never lands cleanly no
|
|
1613
|
-
// second bounce can ever fire this tab (the definitive loop breaker).
|
|
1614
|
-
window.sessionStorage.setItem(ssoAttemptedKey(origin), '1');
|
|
1615
|
-
|
|
1616
|
-
const url = buildSsoBounceUrl(origin, state, oxyServices.config?.authWebUrl);
|
|
1617
|
-
|
|
1618
|
-
// TERMINAL: the document is torn down by this navigation. The
|
|
1619
|
-
// `skip` below is only reached if `assign` is a no-op (e.g. the
|
|
1620
|
-
// navigation is blocked); in that case we fall through
|
|
1621
|
-
// unauthenticated, which is correct.
|
|
1622
|
-
ssoNavigate(url);
|
|
1623
|
-
return { kind: 'skip' };
|
|
1624
|
-
},
|
|
1625
|
-
},
|
|
1626
|
-
],
|
|
884
|
+
await runSessionColdBoot({
|
|
885
|
+
oxy: oxyServices,
|
|
886
|
+
store: authStore,
|
|
887
|
+
platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
|
|
888
|
+
onSession: async (session) => {
|
|
889
|
+
// Bound how long auth resolution waits for the handoff — the token is
|
|
890
|
+
// already planted, so on a slow backend we proceed and let the handoff
|
|
891
|
+
// land asynchronously (the socket subscription re-projects when it does).
|
|
892
|
+
const handoff = commitSessionRef.current(
|
|
893
|
+
{ sessionId: session.sessionId, accessToken: session.accessToken, userId: session.userId },
|
|
894
|
+
{ activate: false },
|
|
895
|
+
);
|
|
896
|
+
let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
|
|
897
|
+
await Promise.race([
|
|
898
|
+
handoff,
|
|
899
|
+
new Promise<void>((resolve) => {
|
|
900
|
+
handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE);
|
|
901
|
+
}),
|
|
902
|
+
]).finally(() => {
|
|
903
|
+
if (handoffDeadlineId !== undefined) {
|
|
904
|
+
clearTimeout(handoffDeadlineId);
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
markAuthResolvedRef.current();
|
|
908
|
+
},
|
|
909
|
+
onSignedOut: () => {
|
|
910
|
+
markAuthResolvedRef.current();
|
|
911
|
+
},
|
|
1627
912
|
onStepError: (id, error) => {
|
|
1628
913
|
if (__DEV__) {
|
|
1629
914
|
loggerUtil.debug(
|
|
1630
915
|
`Cold-boot step "${id}" errored (non-fatal, falling through)`,
|
|
1631
|
-
{ component: 'OxyContext', method: '
|
|
916
|
+
{ component: 'OxyContext', method: 'runColdBoot' },
|
|
1632
917
|
error,
|
|
1633
918
|
);
|
|
1634
919
|
}
|
|
1635
920
|
},
|
|
1636
|
-
// Defense-in-depth: a single step whose promise never settles can no
|
|
1637
|
-
// longer block the chain forever. On expiry the runner keeps iterating
|
|
1638
|
-
// to the terminal `sso-bounce`
|
|
1639
|
-
// step so a genuine no-local-session visit still reaches the
|
|
1640
|
-
// cross-domain `/sso` fallback; the `finally` backstop flips
|
|
1641
|
-
// `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
|
|
1642
|
-
overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
|
|
1643
|
-
onStepDeadline: (id) => {
|
|
1644
|
-
if (__DEV__) {
|
|
1645
|
-
loggerUtil.debug(
|
|
1646
|
-
`Cold-boot step "${id}" exceeded the overall deadline (abandoned, falling through)`,
|
|
1647
|
-
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1648
|
-
);
|
|
1649
|
-
}
|
|
1650
|
-
},
|
|
1651
921
|
});
|
|
1652
|
-
|
|
1653
|
-
if (__DEV__ && outcome.kind === 'session') {
|
|
1654
|
-
loggerUtil.debug(
|
|
1655
|
-
`Cold boot recovered a session via "${outcome.via}"`,
|
|
1656
|
-
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1657
|
-
);
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
|
-
// TOKEN LADDER → SESSIONCLIENT AUTHORITY HANDOFF. The steps above are
|
|
1661
|
-
// ONLY a token-acquisition ladder — they mint the first per-domain
|
|
1662
|
-
// access token by whichever means recovers one fastest. Once a session
|
|
1663
|
-
// is known (either this cold boot committed one via the ladder, or an
|
|
1664
|
-
// access token is already held in memory — e.g. a prior render already
|
|
1665
|
-
// planted it), hand off to the server-authoritative `SessionClient`:
|
|
1666
|
-
// `addCurrentAccount` registers this recovered account+session into the
|
|
1667
|
-
// server `DeviceSession` (derives identity from the bearer), `start`
|
|
1668
|
-
// bootstraps the full device-session state (server `activeAccountId` +
|
|
1669
|
-
// `activeToken`) and connects the realtime socket, and `syncFromClient`
|
|
1670
|
-
// projects that state onto the exposed `sessions`/`activeSessionId`/
|
|
1671
|
-
// `user`. This is what lets a switched (managed/org) account survive
|
|
1672
|
-
// reload: the SERVER, not a client-persisted slot, owns which account is
|
|
1673
|
-
// active. Never call the client when no session was acquired — an
|
|
1674
|
-
// anonymous visitor must stay logged out. Failures are logged and
|
|
1675
|
-
// swallowed; they must never throw out of cold boot.
|
|
1676
|
-
//
|
|
1677
|
-
// `addCurrentAccount` is SKIPPED when `registeredDuringBootRef` is
|
|
1678
|
-
// already `true` — every ladder step except `stored-session` commits
|
|
1679
|
-
// through `handleWebSSOSession`, which registers the account itself
|
|
1680
|
-
// (see its own `sessionClient.addCurrentAccount()` call). Without this
|
|
1681
|
-
// guard a winning `sso-return` / `shared-key-signin` / `silent-iframe`
|
|
1682
|
-
// step would register the SAME account twice
|
|
1683
|
-
// (`POST /session/device/add` called back-to-back). `start()` and
|
|
1684
|
-
// `syncFromClient()` still always run — `start()` is idempotent
|
|
1685
|
-
// (no-ops once already started) and is what connects the realtime
|
|
1686
|
-
// socket for the first time.
|
|
1687
|
-
if (outcome.kind === 'session' || oxyServices.getAccessToken()) {
|
|
1688
|
-
// Self-contained: never throws (own try/catch), so it is safe to let it
|
|
1689
|
-
// outlive the race below and finish in the background if the deadline
|
|
1690
|
-
// trips — the socket subscription then projects the state when it lands.
|
|
1691
|
-
const handoff = (async () => {
|
|
1692
|
-
try {
|
|
1693
|
-
if (!registeredDuringBootRef.current) {
|
|
1694
|
-
// Nested guard: registering this account into the device set is
|
|
1695
|
-
// best-effort. If it throws, `start()` must STILL run so the
|
|
1696
|
-
// realtime socket connects and state projects — cold boot
|
|
1697
|
-
// re-registers the account into the device set on the next load.
|
|
1698
|
-
try {
|
|
1699
|
-
await sessionClient.addCurrentAccount();
|
|
1700
|
-
} catch (addAccountErr) {
|
|
1701
|
-
loggerUtil.warn(
|
|
1702
|
-
'cold-boot: addCurrentAccount failed; continuing to start()',
|
|
1703
|
-
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1704
|
-
addAccountErr as unknown,
|
|
1705
|
-
);
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
await sessionClient.start();
|
|
1709
|
-
await syncFromClient();
|
|
1710
|
-
} catch (startErr) {
|
|
1711
|
-
loggerUtil.warn(
|
|
1712
|
-
'cold-boot: SessionClient start failed',
|
|
1713
|
-
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1714
|
-
startErr as unknown,
|
|
1715
|
-
);
|
|
1716
|
-
}
|
|
1717
|
-
})();
|
|
1718
|
-
// Bound how long auth resolution waits for the handoff (see
|
|
1719
|
-
// `SESSION_HANDOFF_DEADLINE`): the token is already planted, so on a
|
|
1720
|
-
// slow backend we proceed to `markAuthResolved` and let the handoff
|
|
1721
|
-
// complete asynchronously rather than stalling the spinner.
|
|
1722
|
-
let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
|
|
1723
|
-
await Promise.race([
|
|
1724
|
-
handoff,
|
|
1725
|
-
new Promise<void>((resolve) => {
|
|
1726
|
-
handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE);
|
|
1727
|
-
}),
|
|
1728
|
-
]).finally(() => {
|
|
1729
|
-
if (handoffDeadlineId !== undefined) {
|
|
1730
|
-
clearTimeout(handoffDeadlineId);
|
|
1731
|
-
}
|
|
1732
|
-
});
|
|
1733
|
-
}
|
|
1734
922
|
} catch (error) {
|
|
1735
923
|
if (__DEV__) {
|
|
1736
|
-
loggerUtil.error(
|
|
924
|
+
loggerUtil.error(
|
|
925
|
+
'Cold boot error',
|
|
926
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
927
|
+
{ component: 'OxyContext', method: 'runColdBoot' },
|
|
928
|
+
);
|
|
1737
929
|
}
|
|
1738
|
-
await clearSessionStateRef.current();
|
|
1739
930
|
} finally {
|
|
1740
|
-
// Backstop:
|
|
1741
|
-
|
|
1742
|
-
// `stored-session` step), so the gate can never hang `false`. Idempotent
|
|
1743
|
-
// via `markAuthResolved`'s ref: when a commit site already flipped it
|
|
1744
|
-
// mid-chain (the common reload case), this is a no-op. When no session was
|
|
1745
|
-
// recovered (the unauthenticated/error path), this is where `tokenReady` +
|
|
1746
|
-
// `authResolved` finally flip. Monotonic — never reverts on later restores.
|
|
1747
|
-
markAuthResolved();
|
|
931
|
+
// Backstop: resolve on every exit path so the gate can never hang.
|
|
932
|
+
markAuthResolvedRef.current();
|
|
1748
933
|
}
|
|
1749
|
-
}, [
|
|
1750
|
-
oxyServices,
|
|
1751
|
-
storage,
|
|
1752
|
-
storageKeys.priorSession,
|
|
1753
|
-
restoreStoredSession,
|
|
1754
|
-
runSsoReturn,
|
|
1755
|
-
markAuthResolved,
|
|
1756
|
-
sessionClient,
|
|
1757
|
-
syncFromClient,
|
|
1758
|
-
]);
|
|
934
|
+
}, [oxyServices, authStore]);
|
|
1759
935
|
|
|
1760
936
|
useEffect(() => {
|
|
1761
937
|
if (!storage || initialized) {
|
|
1762
938
|
return;
|
|
1763
939
|
}
|
|
1764
|
-
|
|
1765
940
|
setInitialized(true);
|
|
1766
|
-
|
|
941
|
+
runColdBoot().catch((error) => {
|
|
1767
942
|
if (__DEV__) {
|
|
1768
|
-
logger('
|
|
943
|
+
logger('Cold boot failed', error);
|
|
1769
944
|
}
|
|
1770
945
|
});
|
|
1771
|
-
}, [
|
|
1772
|
-
|
|
1773
|
-
// bfcache re-evaluation (web only, registered once). When a page is restored
|
|
1774
|
-
// from the back/forward cache (`e.persisted`) NO cold boot re-runs — React
|
|
1775
|
-
// state is resurrected as-is — yet the page may have been frozen mid-bounce
|
|
1776
|
-
// and resurrected ON the SSO callback with a fresh fragment in the URL. Re-run
|
|
1777
|
-
// the `sso-return` parse so the opaque code is still exchanged (and the
|
|
1778
|
-
// fragment stripped + NO_SESSION flag maintained) on a bfcache restore. Routed
|
|
1779
|
-
// through a ref so the listener registers exactly once and never churns with
|
|
1780
|
-
// `runSsoReturn`'s identity.
|
|
1781
|
-
const runSsoReturnRef = useRef(runSsoReturn);
|
|
1782
|
-
runSsoReturnRef.current = runSsoReturn;
|
|
1783
|
-
|
|
1784
|
-
useEffect(() => {
|
|
1785
|
-
if (!isWebBrowser()) {
|
|
1786
|
-
return;
|
|
1787
|
-
}
|
|
1788
|
-
const onPageShow = (event: PageTransitionEvent) => {
|
|
1789
|
-
if (!event.persisted) {
|
|
1790
|
-
return;
|
|
1791
|
-
}
|
|
1792
|
-
runSsoReturnRef.current().catch((error) => {
|
|
1793
|
-
if (__DEV__) {
|
|
1794
|
-
loggerUtil.debug(
|
|
1795
|
-
'bfcache SSO return re-evaluation failed (non-fatal)',
|
|
1796
|
-
{ component: 'OxyContext', method: 'onPageShow' },
|
|
1797
|
-
error,
|
|
1798
|
-
);
|
|
1799
|
-
}
|
|
1800
|
-
});
|
|
1801
|
-
};
|
|
1802
|
-
window.addEventListener('pageshow', onPageShow);
|
|
1803
|
-
return () => window.removeEventListener('pageshow', onPageShow);
|
|
1804
|
-
}, []);
|
|
1805
|
-
|
|
1806
|
-
// EAGER, universal SSO-callback interception (web only, once on mount).
|
|
1807
|
-
//
|
|
1808
|
-
// When the central IdP redirects the RP back to the internal callback path
|
|
1809
|
-
// ({@link SSO_CALLBACK_PATH}), the app's own router would otherwise mount on
|
|
1810
|
-
// `/__oxy/sso-callback` — a route NO app declares — and briefly flash its
|
|
1811
|
-
// +not-found screen before the storage-gated cold-boot `sso-return` step gets
|
|
1812
|
-
// a chance to strip the fragment and restore the real destination.
|
|
1813
|
-
//
|
|
1814
|
-
// This effect fires the SAME `runSsoReturn` kernel the instant we hydrate ON
|
|
1815
|
-
// the callback path, BEFORE the cold boot (which awaits storage init). The
|
|
1816
|
-
// first render intentionally matches the app/router's static HTML; the
|
|
1817
|
-
// browser layout effect then hides the internal route and consumes the
|
|
1818
|
-
// callback before the first visible paint. That keeps SSR/SSG hydration stable
|
|
1819
|
-
// while still ensuring no app needs a `/__oxy/sso-callback` route.
|
|
1820
|
-
//
|
|
1821
|
-
// It is purely ADDITIVE. The later cold-boot `sso-return` step stays as
|
|
1822
|
-
// defense-in-depth for the non-callback-path case; `consumeSsoReturn` strips
|
|
1823
|
-
// the fragment first, so once this eager pass has run the cold-boot step is a
|
|
1824
|
-
// harmless idempotent no-op (a second parse of the now-fragment-less URL
|
|
1825
|
-
// returns `null`). The path guard scopes this strictly to the callback path,
|
|
1826
|
-
// so a normal page load is untouched. Routed through `runSsoReturnRef` (the
|
|
1827
|
-
// SAME ref the bfcache handler uses) so deps stay `[]` and it registers once.
|
|
1828
|
-
//
|
|
1829
|
-
// Timing: `handleWebSSOSessionRef.current` is assigned SYNCHRONOUSLY during
|
|
1830
|
-
// render (see below, around the `handleWebSSOSession` declaration), and effects
|
|
1831
|
-
// run only after the render commits, so on an `ok` outcome the commit path is
|
|
1832
|
-
// already wired when this fires at eager-mount time. If for any reason it were
|
|
1833
|
-
// not yet set, the later cold-boot `sso-return` step would commit it — but the
|
|
1834
|
-
// ref IS set during render, so the eager `ok` commit works.
|
|
1835
|
-
useBrowserLayoutEffect(() => {
|
|
1836
|
-
if (!isOnSsoCallbackPath()) {
|
|
1837
|
-
setSsoCallbackIntercepting(false);
|
|
1838
|
-
return;
|
|
1839
|
-
}
|
|
1840
|
-
let mounted = true;
|
|
1841
|
-
setSsoCallbackIntercepting(true);
|
|
1842
|
-
runSsoReturnRef.current().catch((error) => {
|
|
1843
|
-
if (__DEV__) {
|
|
1844
|
-
loggerUtil.debug(
|
|
1845
|
-
'Eager SSO callback interception failed (non-fatal)',
|
|
1846
|
-
{ component: 'OxyContext', method: 'eagerSsoCallbackIntercept' },
|
|
1847
|
-
error,
|
|
1848
|
-
);
|
|
1849
|
-
}
|
|
1850
|
-
}).finally(() => {
|
|
1851
|
-
if (mounted) {
|
|
1852
|
-
setSsoCallbackIntercepting(false);
|
|
1853
|
-
}
|
|
1854
|
-
});
|
|
1855
|
-
|
|
1856
|
-
return () => {
|
|
1857
|
-
mounted = false;
|
|
1858
|
-
};
|
|
1859
|
-
}, []);
|
|
1860
|
-
|
|
1861
|
-
// Web SSO: automatically check for cross-domain session on web platforms.
|
|
1862
|
-
// Updates all state and persists session metadata.
|
|
1863
|
-
const handleWebSSOSession = useCallback(async (session: SessionLoginResponse) => {
|
|
1864
|
-
if (!session?.user || !session?.sessionId) {
|
|
1865
|
-
if (__DEV__) {
|
|
1866
|
-
loggerUtil.warn('handleWebSSOSession: Invalid session', { component: 'OxyContext' });
|
|
1867
|
-
}
|
|
1868
|
-
return;
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
if (!session.accessToken) {
|
|
1872
|
-
throw new Error('Session response did not include an access token');
|
|
1873
|
-
}
|
|
1874
|
-
oxyServices.httpService.setTokens(session.accessToken);
|
|
1875
|
-
|
|
1876
|
-
// A committed web session re-enables automatic silent restore: clear the
|
|
1877
|
-
// durable "deliberately signed out" flag. This funnel is reached by deliberate
|
|
1878
|
-
// sign-ins (password, `/sso` return) AND by the silent
|
|
1879
|
-
// cold-boot steps — but those are GATED on the flag, so when it is set they
|
|
1880
|
-
// never run and never reach here; clearing is therefore only hit on a genuine
|
|
1881
|
-
// (re-)sign-in or when restore was already permitted, both correct.
|
|
1882
|
-
clearSignedOut();
|
|
1883
|
-
|
|
1884
|
-
// Register this recovered account+session into the server-authoritative
|
|
1885
|
-
// device-session set. Every web primary-session restore funnels through
|
|
1886
|
-
// here — per-apex `/auth/silent` iframe,
|
|
1887
|
-
// central `/sso` return, and keyless password sign-in — and each of them
|
|
1888
|
-
// needs the resulting account added to the device's `DeviceSession` doc so
|
|
1889
|
-
// the sign-in persists across reload / other tabs / devices and the
|
|
1890
|
-
// realtime `device:<deviceId>` socket has an account to track.
|
|
1891
|
-
// `sessionClient.addCurrentAccount()` (`POST /session/device/add`) derives
|
|
1892
|
-
// identity from the bearer this function just planted above;
|
|
1893
|
-
// `syncFromClient()` reprojects the resulting server state onto the
|
|
1894
|
-
// exposed sessions/activeSessionId/user. Best-effort: a failure here must
|
|
1895
|
-
// NEVER fail the sign-in itself — cold boot re-registers this account into
|
|
1896
|
-
// the device set on the next load regardless. `registeredDuringBootRef` is
|
|
1897
|
-
// flipped on success so the cold-boot post-ladder handoff (which reaches
|
|
1898
|
-
// every step, including this one) does not redundantly re-register the
|
|
1899
|
-
// SAME account with a second `POST /session/device/add`.
|
|
1900
|
-
try {
|
|
1901
|
-
await sessionClient.addCurrentAccount();
|
|
1902
|
-
registeredDuringBootRef.current = true;
|
|
1903
|
-
await syncFromClient();
|
|
1904
|
-
} catch (registrationError) {
|
|
1905
|
-
loggerUtil.warn(
|
|
1906
|
-
'handleWebSSOSession: failed to register session into device set',
|
|
1907
|
-
{ component: 'OxyContext', method: 'handleWebSSOSession' },
|
|
1908
|
-
registrationError as unknown,
|
|
1909
|
-
);
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
const clientSession = {
|
|
1913
|
-
sessionId: session.sessionId,
|
|
1914
|
-
deviceId: session.deviceId || '',
|
|
1915
|
-
expiresAt: session.expiresAt || new Date(Date.now() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
|
|
1916
|
-
lastActive: new Date().toISOString(),
|
|
1917
|
-
userId: session.user.id?.toString() ?? '',
|
|
1918
|
-
isCurrent: true,
|
|
1919
|
-
};
|
|
946
|
+
}, [runColdBoot, storage, initialized, logger]);
|
|
1920
947
|
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
// Persist to storage BEFORE fetching the profile and BEFORE notifying the
|
|
1925
|
-
// auth-state-change callback. The token is planted and the in-memory store
|
|
1926
|
-
// is updated above; durably committing the session id here — ahead of
|
|
1927
|
-
// `getCurrentUser()` / `loginSuccess` / `onAuthStateChange` — is critical
|
|
1928
|
-
// because `onAuthStateChange` triggers a route navigation that can
|
|
1929
|
-
// interrupt/supersede a still-pending async write. Persisting first
|
|
1930
|
-
// guarantees the durable record lands; that is exactly what was missing
|
|
1931
|
-
// when `oxy_session_session_ids` came back empty after a successful SSO
|
|
1932
|
-
// sign-in (the user appeared logged in until reload, then had no session to
|
|
1933
|
-
// restore). `persistSessionDurably` awaits the READY storage instance rather
|
|
1934
|
-
// than reading the possibly-null `storage` state, so a sign-in fired the
|
|
1935
|
-
// instant the screen mounts (before storage-init populates state) is not
|
|
1936
|
-
// silently dropped.
|
|
1937
|
-
await persistSessionDurably(session.sessionId);
|
|
1938
|
-
|
|
1939
|
-
// Fetch the full user profile now that we have a valid access token and the
|
|
1940
|
-
// session is durably persisted. The session only carries MinimalUserData;
|
|
1941
|
-
// the store and callbacks expect a full User. The navigation kicked off by
|
|
1942
|
-
// `onAuthStateChange` now happens only after the durable write is committed.
|
|
1943
|
-
let fullUser: User;
|
|
1944
|
-
try {
|
|
1945
|
-
fullUser = await oxyServices.getCurrentUser();
|
|
1946
|
-
} catch (profileError) {
|
|
1947
|
-
if (__DEV__) {
|
|
1948
|
-
loggerUtil.debug('Failed to fetch full user after web session; using session user fallback', { component: 'OxyContext', method: 'handleWebSSOSession' }, profileError as unknown);
|
|
1949
|
-
}
|
|
1950
|
-
// If the profile fetch fails, fall back to the minimal data from the session
|
|
1951
|
-
// so the user is still logged in (the store accepts User, but the shapes overlap at runtime).
|
|
1952
|
-
fullUser = session.user as unknown as User;
|
|
1953
|
-
}
|
|
1954
|
-
loginSuccess(fullUser);
|
|
1955
|
-
// A session is now committed (per-apex iframe /
|
|
1956
|
-
// SSO-return all funnel through here) — unblock the auth-resolution
|
|
1957
|
-
// gate immediately, ahead of the cold-boot chain returning (idempotent).
|
|
1958
|
-
markAuthResolvedRef.current();
|
|
1959
|
-
onAuthStateChange?.(fullUser);
|
|
1960
|
-
}, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably, sessionClient, syncFromClient]);
|
|
1961
|
-
|
|
1962
|
-
// Expose `handleWebSSOSession` to the cold-boot iframe/SSO steps,
|
|
1963
|
-
// which reference it through a ref because they are declared above this
|
|
1964
|
-
// callback. Assigned synchronously on every render so the ref is populated
|
|
1965
|
-
// before the cold-boot effect (gated on `storage`/`initialized`) can fire.
|
|
1966
|
-
handleWebSSOSessionRef.current = handleWebSSOSession;
|
|
1967
|
-
|
|
1968
|
-
// Keyless native sign-in: username/email + password. The slimmed Accounts app
|
|
1969
|
-
// (which no longer holds a local identity key) uses this; it commits a
|
|
1970
|
-
// successful session through the SAME `handleWebSSOSession` path SSO
|
|
1971
|
-
// use, so state, durable persistence, profile fetch, and `markAuthResolved`
|
|
1972
|
-
// all run identically. The API returns either a full session OR a two-factor
|
|
1973
|
-
// handoff (`{ twoFactorRequired, loginToken }`) — we surface the latter as a
|
|
1974
|
-
// discriminated result so the caller can run the 2FA challenge without any
|
|
1975
|
-
// session being committed.
|
|
1976
|
-
const signInWithPassword = useCallback(
|
|
1977
|
-
async (
|
|
1978
|
-
identifier: string,
|
|
1979
|
-
password: string,
|
|
1980
|
-
opts?: { deviceName?: string; deviceFingerprint?: string },
|
|
1981
|
-
): Promise<PasswordSignInResult> => {
|
|
1982
|
-
// On a cross-apex web RP a direct password sign-in mints a bearer against
|
|
1983
|
-
// the Oxy API but establishes no `fedcm_session`, so the session would be
|
|
1984
|
-
// lost on reload. Refuse it and direct the app to the durable IdP popup
|
|
1985
|
-
// ("Continue with Oxy"). Native and same-apex `*.oxy.so` are unaffected.
|
|
1986
|
-
if (isCrossApexWeb()) {
|
|
1987
|
-
throw new CrossApexDirectSignInError();
|
|
1988
|
-
}
|
|
1989
|
-
const response = await oxyServices.signIn(
|
|
1990
|
-
identifier,
|
|
1991
|
-
password,
|
|
1992
|
-
opts?.deviceName,
|
|
1993
|
-
opts?.deviceFingerprint,
|
|
1994
|
-
);
|
|
1995
|
-
// Core types `signIn` as `SessionLoginResponse`, but the API may instead
|
|
1996
|
-
// return a 2FA challenge handoff. Widen with optional fields (no `any`) to
|
|
1997
|
-
// read them — the added members are optional, so the base type is
|
|
1998
|
-
// assignable to this intersection.
|
|
1999
|
-
const maybeTwoFactor: SessionLoginResponse & {
|
|
2000
|
-
twoFactorRequired?: boolean;
|
|
2001
|
-
loginToken?: string;
|
|
2002
|
-
} = response;
|
|
2003
|
-
if (maybeTwoFactor.twoFactorRequired && maybeTwoFactor.loginToken) {
|
|
2004
|
-
return { status: '2fa_required', loginToken: maybeTwoFactor.loginToken };
|
|
2005
|
-
}
|
|
2006
|
-
// Full session — commit it through the shared web-session path.
|
|
2007
|
-
await handleWebSSOSession(response);
|
|
2008
|
-
return { status: 'ok' };
|
|
2009
|
-
},
|
|
2010
|
-
[oxyServices, handleWebSSOSession],
|
|
2011
|
-
);
|
|
2012
|
-
|
|
2013
|
-
// Cross-domain silent SSO is owned ENTIRELY by the `silent-iframe` cold-boot
|
|
2014
|
-
// step above (the ordered `runColdBoot` sequence) plus the terminal
|
|
2015
|
-
// `sso-bounce` fallback. There is no post-boot safety net here: the FedCM
|
|
2016
|
-
// silent post-boot retry that used to run via `useWebSSO` was removed along
|
|
2017
|
-
// with the rest of the client FedCM surface (Chrome-only; see
|
|
2018
|
-
// `CrossDomainAuth`'s doc comment in `@oxyhq/core` for the production
|
|
2019
|
-
// incident that motivated the removal).
|
|
2020
|
-
|
|
2021
|
-
// IdP session validation via lightweight iframe check
|
|
2022
|
-
// When user returns to tab, verify auth.oxy.so still has their session
|
|
2023
|
-
// If session is gone (cleared/logged out), clear local session too
|
|
2024
|
-
//
|
|
2025
|
-
// KEPT (session-sync cutover audit, Task 5): this is NOT redundant with the
|
|
2026
|
-
// `SessionClient` socket (`device:<deviceId>`, `session_state` push). That
|
|
2027
|
-
// socket only fires for revocations that flow through oxy-api's
|
|
2028
|
-
// `DeviceSession` authority (`POST /session/device/signout` and friends). A
|
|
2029
|
-
// sign-out at the CENTRAL IdP itself (`auth.oxy.so`'s own `AuthSession` /
|
|
2030
|
-
// `fedcm_session` — e.g. a direct IdP-side sign-out, or an admin/security
|
|
2031
|
-
// revocation there) does not currently touch `DeviceSession` at all, so no
|
|
2032
|
-
// socket push follows — this iframe poll is the ONLY mechanism that catches
|
|
2033
|
-
// it today. A future phase should move this server-side (IdP deactivation
|
|
2034
|
-
// -> `DeviceSession` update -> broadcast over the same socket), at which
|
|
2035
|
-
// point this client-side poll becomes redundant and can be deleted.
|
|
2036
|
-
const lastIdPCheckRef = useRef<number>(0);
|
|
2037
|
-
const pendingIdPCleanupRef = useRef<(() => void) | null>(null);
|
|
2038
|
-
|
|
2039
|
-
// Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
|
|
2040
|
-
// the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
|
|
2041
|
-
// the prop is undefined but the instance was constructed with the detected
|
|
2042
|
-
// value, so the check must target the same first-party IdP the cold-boot
|
|
2043
|
-
// iframe used.
|
|
2044
|
-
const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
|
|
2045
|
-
|
|
2046
|
-
useEffect(() => {
|
|
2047
|
-
if (!isWebBrowser() || !user || !initialized) return;
|
|
2048
|
-
|
|
2049
|
-
const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
|
|
2050
|
-
|
|
2051
|
-
const checkIdPSession = () => {
|
|
2052
|
-
// Debounce: check at most once per cooldown window
|
|
2053
|
-
const now = Date.now();
|
|
2054
|
-
if (now - lastIdPCheckRef.current < IDP_SESSION_CHECK_COOLDOWN) return;
|
|
2055
|
-
lastIdPCheckRef.current = now;
|
|
2056
|
-
|
|
2057
|
-
// Clean up any in-flight check before starting a new one
|
|
2058
|
-
pendingIdPCleanupRef.current?.();
|
|
2059
|
-
|
|
2060
|
-
// Load hidden iframe to check IdP session via postMessage
|
|
2061
|
-
const iframe = document.createElement('iframe');
|
|
2062
|
-
iframe.style.cssText = 'display:none;width:0;height:0;border:0';
|
|
2063
|
-
iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
|
|
2064
|
-
|
|
2065
|
-
let cleaned = false;
|
|
2066
|
-
const cleanup = () => {
|
|
2067
|
-
if (cleaned) return;
|
|
2068
|
-
cleaned = true;
|
|
2069
|
-
window.removeEventListener('message', handleMessage);
|
|
2070
|
-
iframe.remove();
|
|
2071
|
-
};
|
|
2072
|
-
|
|
2073
|
-
const handleMessage = async (event: MessageEvent) => {
|
|
2074
|
-
if (event.origin !== idpOrigin) return;
|
|
2075
|
-
if (event.data?.type !== 'oxy-session-check') return;
|
|
2076
|
-
cleanup();
|
|
2077
|
-
|
|
2078
|
-
if (!event.data.hasSession) {
|
|
2079
|
-
// Only a SAME-SITE, first-party IdP answer is authoritative enough to
|
|
2080
|
-
// force a local sign-out. On a cross-site / undetermined IdP the
|
|
2081
|
-
// "no session" answer must never clear local state (a third-party
|
|
2082
|
-
// can't be trusted to end this app's session). Surface the toast in
|
|
2083
|
-
// both cases, but gate the destructive `clearSessionState()`.
|
|
2084
|
-
if (isSameSiteIdP(idpOrigin)) {
|
|
2085
|
-
toast.info('Your session has ended. Please sign in again.');
|
|
2086
|
-
await clearSessionState();
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
};
|
|
2090
|
-
|
|
2091
|
-
window.addEventListener('message', handleMessage);
|
|
2092
|
-
document.body.appendChild(iframe);
|
|
2093
|
-
setTimeout(cleanup, IDP_SESSION_CHECK_TIMEOUT);
|
|
2094
|
-
pendingIdPCleanupRef.current = cleanup;
|
|
2095
|
-
};
|
|
2096
|
-
|
|
2097
|
-
const handleVisibilityChange = () => {
|
|
2098
|
-
if (document.visibilityState === 'visible') {
|
|
2099
|
-
checkIdPSession();
|
|
2100
|
-
}
|
|
2101
|
-
};
|
|
2102
|
-
|
|
2103
|
-
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
2104
|
-
return () => {
|
|
2105
|
-
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
2106
|
-
pendingIdPCleanupRef.current?.();
|
|
2107
|
-
pendingIdPCleanupRef.current = null;
|
|
2108
|
-
};
|
|
2109
|
-
}, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
|
|
2110
|
-
|
|
2111
|
-
// Exposed `refreshSessions`: re-bootstraps the server-authoritative device
|
|
2112
|
-
// state (`GET /session/device/state`, the SAME request `SessionClient.start()`
|
|
2113
|
-
// makes) and reprojects it via `syncFromClient` — a manual pull-to-refresh
|
|
2114
|
-
// counterpart to the realtime `device:<deviceId>` socket `client.start()`
|
|
2115
|
-
// already owns (there is no per-domain `useSessionSocket` anymore; a REMOTE
|
|
2116
|
-
// sign-out or account change arrives over that socket and is handled by
|
|
2117
|
-
// `syncFromClient`'s subscription below, including the full-sign-out gap fix
|
|
2118
|
-
// documented above it).
|
|
948
|
+
// Exposed `refreshSessions`: re-bootstrap the server-authoritative device
|
|
949
|
+
// state and reproject — the manual counterpart to the realtime socket.
|
|
2119
950
|
const refreshSessionsForContext = useCallback(async (): Promise<void> => {
|
|
2120
951
|
await sessionClient.bootstrap();
|
|
2121
952
|
await syncFromClient();
|
|
2122
953
|
}, [sessionClient, syncFromClient]);
|
|
2123
954
|
|
|
2124
|
-
// Exposed `switchSession`:
|
|
2125
|
-
// `SessionClient
|
|
2126
|
-
//
|
|
2127
|
-
// internal same-user duplicate-session dedup — a different, legacy
|
|
2128
|
-
// session-validate concept unrelated to switching the device's ACTIVE
|
|
2129
|
-
// account). Resolves the target account from the current device state,
|
|
2130
|
-
// asks the server to switch, reprojects, and returns the now-active user.
|
|
955
|
+
// Exposed `switchSession`: route through the server-authoritative
|
|
956
|
+
// `SessionClient`. Resolve the target account from the current device state,
|
|
957
|
+
// ask the server to switch, reproject, and return the now-active user.
|
|
2131
958
|
const switchSessionForContext = useCallback(
|
|
2132
959
|
async (sessionId: string): Promise<User> => {
|
|
2133
960
|
const targetAccountId = sessionClient
|
|
@@ -2136,10 +963,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2136
963
|
if (!targetAccountId) {
|
|
2137
964
|
throw new Error(`No device account found for session "${sessionId}"`);
|
|
2138
965
|
}
|
|
2139
|
-
|
|
2140
966
|
await sessionClient.switchAccount(targetAccountId);
|
|
2141
967
|
await syncFromClient();
|
|
2142
|
-
|
|
2143
968
|
const activeUser = useAuthStore.getState().user;
|
|
2144
969
|
if (!activeUser) {
|
|
2145
970
|
throw new Error('Active account profile could not be resolved after switch');
|
|
@@ -2149,16 +974,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2149
974
|
[sessionClient, syncFromClient],
|
|
2150
975
|
);
|
|
2151
976
|
|
|
2152
|
-
|
|
2153
|
-
const
|
|
2154
|
-
return KeyManager.hasIdentity();
|
|
2155
|
-
}, []);
|
|
2156
|
-
|
|
2157
|
-
const getPublicKey = useCallback(async (): Promise<string | null> => {
|
|
2158
|
-
return KeyManager.getPublicKey();
|
|
2159
|
-
}, []);
|
|
977
|
+
const hasIdentity = useCallback(async (): Promise<boolean> => KeyManager.hasIdentity(), []);
|
|
978
|
+
const getPublicKey = useCallback(async (): Promise<string | null> => KeyManager.getPublicKey(), []);
|
|
2160
979
|
|
|
2161
|
-
// Create showBottomSheet function that uses the global function
|
|
2162
980
|
const showBottomSheetForContext = useCallback(
|
|
2163
981
|
(screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => {
|
|
2164
982
|
globalShowBottomSheet(screenOrConfig);
|
|
@@ -2166,7 +984,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2166
984
|
[],
|
|
2167
985
|
);
|
|
2168
986
|
|
|
2169
|
-
// Avatar picker extracted into dedicated hook
|
|
2170
987
|
const { openAvatarPicker } = useAvatarPicker({
|
|
2171
988
|
oxyServices,
|
|
2172
989
|
currentLanguage,
|
|
@@ -2175,16 +992,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2175
992
|
showBottomSheet: showBottomSheetForContext,
|
|
2176
993
|
});
|
|
2177
994
|
|
|
2178
|
-
//
|
|
995
|
+
// ── Account graph ──────────────────────────────────────────────────────────
|
|
2179
996
|
const [accounts, setAccounts] = useState<AccountNode[]>([]);
|
|
2180
997
|
|
|
2181
|
-
// Load the unified account graph when authenticated
|
|
2182
998
|
const refreshAccounts = useCallback(async (): Promise<void> => {
|
|
2183
999
|
if (!isAuthenticated || !tokenReady || !oxyServices.getAccessToken()) {
|
|
2184
1000
|
setAccounts([]);
|
|
2185
1001
|
return;
|
|
2186
1002
|
}
|
|
2187
|
-
|
|
2188
1003
|
try {
|
|
2189
1004
|
const list = await oxyServices.listAccounts();
|
|
2190
1005
|
setAccounts(list);
|
|
@@ -2206,221 +1021,159 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2206
1021
|
}
|
|
2207
1022
|
}, [isAuthenticated, initialized, tokenReady, refreshAccounts]);
|
|
2208
1023
|
|
|
2209
|
-
// Shared post-switch side effects, run identically regardless of which
|
|
2210
|
-
// `switchToAccount` branch handled the switch below: reload the switchable
|
|
2211
|
-
// account graph (the new active account's relationships differ) and
|
|
2212
|
-
// invalidate every query so all data refetches as the new account.
|
|
2213
1024
|
const runPostAccountSwitchSideEffects = useCallback(async (): Promise<void> => {
|
|
2214
1025
|
await refreshAccounts();
|
|
2215
1026
|
queryClient.invalidateQueries();
|
|
2216
1027
|
}, [refreshAccounts, queryClient]);
|
|
2217
1028
|
|
|
2218
1029
|
// Switch the active session INTO an account from the unified graph. In the
|
|
2219
|
-
//
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
// minted, so no session churn / deactivation of the account's existing
|
|
2229
|
-
// session (H4).
|
|
2230
|
-
// - Only the FIRST time an account is switched into does it need minting:
|
|
2231
|
-
// `oxyServices.switchToAccount` mints+plants a real session, then
|
|
2232
|
-
// `sessionClient.addCurrentAccount()` registers it into the device set
|
|
2233
|
-
// (server-set httpOnly `oxy_rt_<authuser>` cookie, so the session joins
|
|
2234
|
-
// the device multi-account set and survives reload / `refresh-all`).
|
|
2235
|
-
// Every subsequent switch into that same account takes the branch above.
|
|
2236
|
-
const switchToAccount = useCallback(async (accountId: string): Promise<void> => {
|
|
2237
|
-
const deviceState = sessionClient.getState();
|
|
2238
|
-
if (deviceState?.accounts.some((account) => account.accountId === accountId)) {
|
|
2239
|
-
await sessionClient.switchAccount(accountId);
|
|
2240
|
-
await syncFromClient();
|
|
2241
|
-
await runPostAccountSwitchSideEffects();
|
|
2242
|
-
return;
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
|
-
const result = await oxyServices.switchToAccount(accountId);
|
|
2246
|
-
if (!result?.user || !result?.sessionId) {
|
|
2247
|
-
throw new Error('Account switch did not return a valid session');
|
|
2248
|
-
}
|
|
2249
|
-
|
|
2250
|
-
// A switch is a deliberate sign-in into an account: re-enable automatic silent
|
|
2251
|
-
// restore by clearing any prior "deliberately signed out" flag.
|
|
2252
|
-
clearSignedOut();
|
|
2253
|
-
|
|
2254
|
-
// `oxyServices.switchToAccount` already planted `result.accessToken` as the
|
|
2255
|
-
// active token; mirror the minted session into the multi-account store and
|
|
2256
|
-
// mark it current. The device account SET (and its `authuser` slot
|
|
2257
|
-
// numbering) is server-authoritative via `SessionClient` — the
|
|
2258
|
-
// `addCurrentAccount` + `syncFromClient` reprojection below supersedes this
|
|
2259
|
-
// local mirror with the server's own state.
|
|
2260
|
-
const now = new Date();
|
|
2261
|
-
const clientSession: ClientSession = {
|
|
2262
|
-
sessionId: result.sessionId,
|
|
2263
|
-
deviceId: result.deviceId || '',
|
|
2264
|
-
expiresAt: result.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
|
|
2265
|
-
lastActive: now.toISOString(),
|
|
2266
|
-
userId: result.user.id?.toString() ?? '',
|
|
2267
|
-
isCurrent: true,
|
|
2268
|
-
};
|
|
2269
|
-
updateSessions([clientSession], { merge: true });
|
|
2270
|
-
setActiveSessionId(result.sessionId);
|
|
2271
|
-
await persistSessionDurably(result.sessionId);
|
|
2272
|
-
|
|
2273
|
-
// Register the switched-to account into the device's server-authoritative
|
|
2274
|
-
// multi-account set (the SAME `SessionClient.addCurrentAccount()` the
|
|
2275
|
-
// cold-boot handoff uses — it derives identity from the bearer, which
|
|
2276
|
-
// `oxyServices.switchToAccount` already planted above) and reproject
|
|
2277
|
-
// state, so the switch persists across reload / other tabs / devices
|
|
2278
|
-
// instead of only living in this provider's local session-management
|
|
2279
|
-
// state. Best-effort: the token for the new account is already planted, so
|
|
2280
|
-
// a transient failure here must NEVER reject the switch — fall through to
|
|
2281
|
-
// `loginSuccess`/`onAuthStateChange` so the UI reflects the switched-to
|
|
2282
|
-
// account (cold boot re-registers into the device set on next load).
|
|
2283
|
-
// Mirrors the same guard in `handleWebSSOSession`.
|
|
2284
|
-
try {
|
|
2285
|
-
await sessionClient.addCurrentAccount();
|
|
2286
|
-
await syncFromClient();
|
|
2287
|
-
} catch (registrationError) {
|
|
2288
|
-
loggerUtil.warn(
|
|
2289
|
-
'switchToAccount: failed to register account into device set',
|
|
2290
|
-
{ component: 'OxyContext', method: 'switchToAccount' },
|
|
2291
|
-
registrationError as unknown,
|
|
2292
|
-
);
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
|
-
// Fetch the canonical User for the new account (the switch result carries
|
|
2296
|
-
// only MinimalUserData); fall back to that minimal shape if the profile
|
|
2297
|
-
// fetch fails so the app still reflects the switched identity.
|
|
2298
|
-
let fullUser: User;
|
|
2299
|
-
try {
|
|
2300
|
-
fullUser = await oxyServices.getCurrentUser();
|
|
2301
|
-
} catch (profileError) {
|
|
2302
|
-
if (__DEV__) {
|
|
2303
|
-
loggerUtil.debug('Failed to fetch full user after account switch; using switch result user', { component: 'OxyContext', method: 'switchToAccount' }, profileError as unknown);
|
|
1030
|
+
// real-session model this is identical to switching device sign-ins.
|
|
1031
|
+
const switchToAccount = useCallback(
|
|
1032
|
+
async (accountId: string): Promise<void> => {
|
|
1033
|
+
const deviceState = sessionClient.getState();
|
|
1034
|
+
if (deviceState?.accounts.some((account) => account.accountId === accountId)) {
|
|
1035
|
+
await sessionClient.switchAccount(accountId);
|
|
1036
|
+
await syncFromClient();
|
|
1037
|
+
await runPostAccountSwitchSideEffects();
|
|
1038
|
+
return;
|
|
2304
1039
|
}
|
|
2305
|
-
fullUser = result.user as unknown as User;
|
|
2306
|
-
}
|
|
2307
|
-
loginSuccess(fullUser);
|
|
2308
|
-
onAuthStateChange?.(fullUser);
|
|
2309
1040
|
|
|
2310
|
-
|
|
2311
|
-
|
|
1041
|
+
const result = await oxyServices.switchToAccount(accountId);
|
|
1042
|
+
if (!result?.user || !result?.sessionId) {
|
|
1043
|
+
throw new Error('Account switch did not return a valid session');
|
|
1044
|
+
}
|
|
1045
|
+
// `oxyServices.switchToAccount` already planted the access token; commit
|
|
1046
|
+
// the minted session through the shared funnel (persist + register +
|
|
1047
|
+
// hydrate) as a deliberate activation.
|
|
1048
|
+
await commitSession(
|
|
1049
|
+
{
|
|
1050
|
+
sessionId: result.sessionId,
|
|
1051
|
+
accessToken: result.accessToken,
|
|
1052
|
+
refreshToken: (result as { refreshToken?: string }).refreshToken,
|
|
1053
|
+
deviceId: result.deviceId,
|
|
1054
|
+
expiresAt: result.expiresAt,
|
|
1055
|
+
userId: result.user.id,
|
|
1056
|
+
user: result.user,
|
|
1057
|
+
},
|
|
1058
|
+
{ activate: true },
|
|
1059
|
+
);
|
|
1060
|
+
await runPostAccountSwitchSideEffects();
|
|
1061
|
+
},
|
|
1062
|
+
[oxyServices, sessionClient, syncFromClient, commitSession, runPostAccountSwitchSideEffects],
|
|
1063
|
+
);
|
|
2312
1064
|
|
|
2313
|
-
const createAccountFn = useCallback(
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
1065
|
+
const createAccountFn = useCallback(
|
|
1066
|
+
async (data: CreateAccountInput): Promise<AccountNode> => {
|
|
1067
|
+
const account = await oxyServices.createAccount(data);
|
|
1068
|
+
await refreshAccounts();
|
|
1069
|
+
return account;
|
|
1070
|
+
},
|
|
1071
|
+
[oxyServices, refreshAccounts],
|
|
1072
|
+
);
|
|
2318
1073
|
|
|
2319
1074
|
const canUsePrivateApi = authResolved && isAuthenticated && tokenReady && hasAccessToken;
|
|
2320
1075
|
const isPrivateApiPending = !authResolved || (isAuthenticated && (!tokenReady || !hasAccessToken));
|
|
2321
1076
|
|
|
2322
|
-
const contextValue: OxyContextState = useMemo(
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
<OxyContext.Provider value={contextValue}>
|
|
2413
|
-
{ssoCallbackIntercepting ? null : children}
|
|
2414
|
-
</OxyContext.Provider>
|
|
1077
|
+
const contextValue: OxyContextState = useMemo(
|
|
1078
|
+
() => ({
|
|
1079
|
+
user,
|
|
1080
|
+
sessions,
|
|
1081
|
+
activeSessionId,
|
|
1082
|
+
isAuthenticated,
|
|
1083
|
+
isLoading,
|
|
1084
|
+
isTokenReady: tokenReady,
|
|
1085
|
+
hasAccessToken,
|
|
1086
|
+
canUsePrivateApi,
|
|
1087
|
+
isPrivateApiPending,
|
|
1088
|
+
isAuthResolved: authResolved,
|
|
1089
|
+
isStorageReady: storage !== null,
|
|
1090
|
+
error,
|
|
1091
|
+
currentLanguage,
|
|
1092
|
+
currentLanguageMetadata,
|
|
1093
|
+
currentLanguageName,
|
|
1094
|
+
currentNativeLanguageName,
|
|
1095
|
+
hasIdentity,
|
|
1096
|
+
getPublicKey,
|
|
1097
|
+
signIn,
|
|
1098
|
+
signInWithPassword,
|
|
1099
|
+
completeTwoFactorSignIn,
|
|
1100
|
+
handleWebSession,
|
|
1101
|
+
logout,
|
|
1102
|
+
logoutAll,
|
|
1103
|
+
switchSession: switchSessionForContext,
|
|
1104
|
+
removeSession: logout,
|
|
1105
|
+
refreshSessions: refreshSessionsForContext,
|
|
1106
|
+
setLanguage,
|
|
1107
|
+
getDeviceSessions,
|
|
1108
|
+
logoutAllDeviceSessions,
|
|
1109
|
+
updateDeviceName,
|
|
1110
|
+
clearSessionState,
|
|
1111
|
+
clearAllAccountData,
|
|
1112
|
+
storageKeyPrefix,
|
|
1113
|
+
clientId,
|
|
1114
|
+
oxyServices,
|
|
1115
|
+
useFollow: useFollowHook,
|
|
1116
|
+
showBottomSheet: showBottomSheetForContext,
|
|
1117
|
+
openAvatarPicker,
|
|
1118
|
+
accounts,
|
|
1119
|
+
switchToAccount,
|
|
1120
|
+
refreshAccounts,
|
|
1121
|
+
createAccount: createAccountFn,
|
|
1122
|
+
}),
|
|
1123
|
+
[
|
|
1124
|
+
user,
|
|
1125
|
+
sessions,
|
|
1126
|
+
activeSessionId,
|
|
1127
|
+
isAuthenticated,
|
|
1128
|
+
isLoading,
|
|
1129
|
+
tokenReady,
|
|
1130
|
+
hasAccessToken,
|
|
1131
|
+
canUsePrivateApi,
|
|
1132
|
+
isPrivateApiPending,
|
|
1133
|
+
authResolved,
|
|
1134
|
+
storage,
|
|
1135
|
+
error,
|
|
1136
|
+
currentLanguage,
|
|
1137
|
+
currentLanguageMetadata,
|
|
1138
|
+
currentLanguageName,
|
|
1139
|
+
currentNativeLanguageName,
|
|
1140
|
+
hasIdentity,
|
|
1141
|
+
getPublicKey,
|
|
1142
|
+
signIn,
|
|
1143
|
+
signInWithPassword,
|
|
1144
|
+
completeTwoFactorSignIn,
|
|
1145
|
+
handleWebSession,
|
|
1146
|
+
logout,
|
|
1147
|
+
logoutAll,
|
|
1148
|
+
switchSessionForContext,
|
|
1149
|
+
refreshSessionsForContext,
|
|
1150
|
+
setLanguage,
|
|
1151
|
+
getDeviceSessions,
|
|
1152
|
+
logoutAllDeviceSessions,
|
|
1153
|
+
updateDeviceName,
|
|
1154
|
+
clearSessionState,
|
|
1155
|
+
clearAllAccountData,
|
|
1156
|
+
storageKeyPrefix,
|
|
1157
|
+
clientId,
|
|
1158
|
+
oxyServices,
|
|
1159
|
+
useFollowHook,
|
|
1160
|
+
showBottomSheetForContext,
|
|
1161
|
+
openAvatarPicker,
|
|
1162
|
+
accounts,
|
|
1163
|
+
switchToAccount,
|
|
1164
|
+
refreshAccounts,
|
|
1165
|
+
createAccountFn,
|
|
1166
|
+
],
|
|
2415
1167
|
);
|
|
1168
|
+
|
|
1169
|
+
return <OxyContext.Provider value={contextValue}>{children}</OxyContext.Provider>;
|
|
2416
1170
|
};
|
|
2417
1171
|
|
|
2418
1172
|
export const OxyContextProvider = OxyProvider;
|
|
2419
1173
|
|
|
2420
1174
|
/**
|
|
2421
1175
|
* Loading-state stub used when `useOxy()` is called outside an OxyProvider.
|
|
2422
|
-
* All async methods reject with a clear error so misuse is caught early
|
|
2423
|
-
* instead of silently no-oping and leaving the UI in a bad state.
|
|
1176
|
+
* All async methods reject with a clear error so misuse is caught early.
|
|
2424
1177
|
*/
|
|
2425
1178
|
const PROVIDER_MISSING_ERROR_MESSAGE =
|
|
2426
1179
|
'OxyProvider is not mounted. Wrap your app in <OxyProvider> before calling useOxy() methods.';
|
|
@@ -2428,12 +1181,7 @@ const PROVIDER_MISSING_ERROR_MESSAGE =
|
|
|
2428
1181
|
const rejectMissingProvider = <T,>(): Promise<T> =>
|
|
2429
1182
|
Promise.reject(new Error(PROVIDER_MISSING_ERROR_MESSAGE));
|
|
2430
1183
|
|
|
2431
|
-
|
|
2432
|
-
// Calling network methods on it before a provider mounts will fail with
|
|
2433
|
-
// a descriptive baseURL — preferable to a null-pointer crash at the call site.
|
|
2434
|
-
const LOADING_STATE_OXY_SERVICES = new OxyServices({
|
|
2435
|
-
baseURL: 'about:blank',
|
|
2436
|
-
});
|
|
1184
|
+
const LOADING_STATE_OXY_SERVICES = new OxyServices({ baseURL: 'about:blank' });
|
|
2437
1185
|
|
|
2438
1186
|
const LOADING_STATE: OxyContextState = {
|
|
2439
1187
|
user: null,
|
|
@@ -2456,6 +1204,7 @@ const LOADING_STATE: OxyContextState = {
|
|
|
2456
1204
|
getPublicKey: () => Promise.resolve(null),
|
|
2457
1205
|
signIn: () => rejectMissingProvider<User>(),
|
|
2458
1206
|
signInWithPassword: () => rejectMissingProvider<PasswordSignInResult>(),
|
|
1207
|
+
completeTwoFactorSignIn: () => rejectMissingProvider<void>(),
|
|
2459
1208
|
handleWebSession: () => rejectMissingProvider<void>(),
|
|
2460
1209
|
logout: () => rejectMissingProvider<void>(),
|
|
2461
1210
|
logoutAll: () => rejectMissingProvider<void>(),
|
|
@@ -2485,5 +1234,3 @@ export const useOxy = (): OxyContextState => {
|
|
|
2485
1234
|
}
|
|
2486
1235
|
return context;
|
|
2487
1236
|
};
|
|
2488
|
-
|
|
2489
|
-
export default OxyContext;
|