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