@oxyhq/services 13.3.0 → 14.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 +11 -17
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/AccountMenu.js +103 -65
- package/lib/commonjs/ui/components/AccountMenu.js.map +1 -1
- package/lib/commonjs/ui/components/AccountSwitcher.js +84 -126
- package/lib/commonjs/ui/components/AccountSwitcher.js.map +1 -1
- package/lib/commonjs/ui/components/ProfileMenu.js +140 -153
- package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -1
- package/lib/commonjs/ui/components/SignInModal.js +4 -4
- package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +506 -466
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +59 -57
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js +23 -42
- package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -1
- package/lib/commonjs/ui/context/silentSessionRestore.js +12 -21
- package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -1
- package/lib/commonjs/ui/hooks/useAuth.js +4 -3
- package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
- package/lib/commonjs/ui/hooks/useOxyAuthSession.js +12 -7
- package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +17 -65
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +288 -0
- package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -0
- package/lib/commonjs/ui/hooks/useWebSSO.js +12 -191
- package/lib/commonjs/ui/hooks/useWebSSO.js.map +1 -1
- package/lib/commonjs/ui/index.js.map +1 -1
- package/lib/commonjs/ui/screens/OxyAuthScreen.js +2 -2
- package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/commonjs/ui/session/createSessionClient.js +28 -0
- package/lib/commonjs/ui/session/createSessionClient.js.map +1 -0
- package/lib/commonjs/ui/session/index.js +52 -0
- package/lib/commonjs/ui/session/index.js.map +1 -0
- package/lib/commonjs/ui/session/projectSessionState.js +86 -0
- package/lib/commonjs/ui/session/projectSessionState.js.map +1 -0
- package/lib/commonjs/ui/session/sessionClientHost.js +30 -0
- package/lib/commonjs/ui/session/sessionClientHost.js.map +1 -0
- package/lib/commonjs/ui/session/tokenTransport.js +77 -0
- package/lib/commonjs/ui/session/tokenTransport.js.map +1 -0
- package/lib/commonjs/ui/utils/activeAuthuser.js +35 -79
- package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
- package/lib/commonjs/ui/utils/sessionHelpers.js +1 -51
- package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
- package/lib/commonjs/utils/deviceFlowSignIn.js +70 -15
- package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
- package/lib/module/index.js +5 -5
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/AccountMenu.js +103 -65
- package/lib/module/ui/components/AccountMenu.js.map +1 -1
- package/lib/module/ui/components/AccountSwitcher.js +85 -127
- package/lib/module/ui/components/AccountSwitcher.js.map +1 -1
- package/lib/module/ui/components/ProfileMenu.js +143 -156
- package/lib/module/ui/components/ProfileMenu.js.map +1 -1
- package/lib/module/ui/components/SignInModal.js +4 -4
- package/lib/module/ui/components/SignInModal.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +510 -470
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +61 -60
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/inSessionTokenRefresh.js +24 -43
- package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -1
- package/lib/module/ui/context/silentSessionRestore.js +12 -20
- package/lib/module/ui/context/silentSessionRestore.js.map +1 -1
- package/lib/module/ui/hooks/useAuth.js +4 -3
- package/lib/module/ui/hooks/useAuth.js.map +1 -1
- package/lib/module/ui/hooks/useOxyAuthSession.js +12 -7
- package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +18 -66
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/hooks/useSwitchableAccounts.js +283 -0
- package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -0
- package/lib/module/ui/hooks/useWebSSO.js +13 -192
- package/lib/module/ui/hooks/useWebSSO.js.map +1 -1
- package/lib/module/ui/index.js +0 -1
- package/lib/module/ui/index.js.map +1 -1
- package/lib/module/ui/screens/OxyAuthScreen.js +2 -2
- package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
- package/lib/module/ui/session/createSessionClient.js +25 -0
- package/lib/module/ui/session/createSessionClient.js.map +1 -0
- package/lib/module/ui/session/index.js +17 -0
- package/lib/module/ui/session/index.js.map +1 -0
- package/lib/module/ui/session/projectSessionState.js +79 -0
- package/lib/module/ui/session/projectSessionState.js.map +1 -0
- package/lib/module/ui/session/sessionClientHost.js +26 -0
- package/lib/module/ui/session/sessionClientHost.js.map +1 -0
- package/lib/module/ui/session/tokenTransport.js +74 -0
- package/lib/module/ui/session/tokenTransport.js.map +1 -0
- package/lib/module/ui/utils/activeAuthuser.js +34 -76
- package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
- package/lib/module/ui/utils/sessionHelpers.js +0 -49
- package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
- package/lib/module/utils/deviceFlowSignIn.js +71 -16
- package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +2 -3
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts +4 -2
- package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +7 -8
- package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +12 -9
- package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +16 -13
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +27 -3
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +22 -17
- package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +12 -18
- package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +2 -2
- package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts +162 -0
- package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +13 -52
- package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +15 -0
- package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/index.d.ts +15 -0
- package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +45 -0
- package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +14 -0
- package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +24 -0
- package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +21 -29
- package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts +0 -19
- package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
- package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +51 -19
- package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +2 -3
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountMenu.d.ts +4 -2
- package/lib/typescript/module/ui/components/AccountMenu.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +7 -8
- package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ProfileMenu.d.ts +12 -9
- package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +16 -13
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +27 -3
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +22 -17
- package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +12 -18
- package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useAuth.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +2 -2
- package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts +162 -0
- package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +13 -52
- package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +1 -1
- package/lib/typescript/module/ui/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/createSessionClient.d.ts +15 -0
- package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/index.d.ts +15 -0
- package/lib/typescript/module/ui/session/index.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/projectSessionState.d.ts +45 -0
- package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/sessionClientHost.d.ts +14 -0
- package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +1 -0
- package/lib/typescript/module/ui/session/tokenTransport.d.ts +24 -0
- package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +21 -29
- package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/sessionHelpers.d.ts +0 -19
- package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
- package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +51 -19
- package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +9 -9
- package/src/ui/components/AccountMenu.tsx +117 -70
- package/src/ui/components/AccountSwitcher.tsx +88 -134
- package/src/ui/components/ProfileMenu.tsx +158 -162
- package/src/ui/components/SignInModal.tsx +5 -5
- package/src/ui/context/OxyContext.tsx +560 -475
- package/src/ui/context/hooks/useAuthOperations.ts +72 -63
- package/src/ui/context/inSessionTokenRefresh.ts +23 -42
- package/src/ui/context/silentSessionRestore.ts +12 -26
- package/src/ui/hooks/useAuth.ts +4 -3
- package/src/ui/hooks/useOxyAuthSession.ts +13 -8
- package/src/ui/hooks/useSessionManagement.ts +16 -87
- package/src/ui/hooks/useSwitchableAccounts.ts +397 -0
- package/src/ui/hooks/useWebSSO.ts +13 -227
- package/src/ui/index.ts +0 -1
- package/src/ui/screens/OxyAuthScreen.tsx +2 -2
- package/src/ui/session/__tests__/createSessionClient.test.ts +41 -0
- package/src/ui/session/__tests__/projectSessionState.test.ts +114 -0
- package/src/ui/session/__tests__/sessionClientHost.test.ts +43 -0
- package/src/ui/session/__tests__/tokenTransport.test.ts +142 -0
- package/src/ui/session/createSessionClient.ts +21 -0
- package/src/ui/session/index.ts +19 -0
- package/src/ui/session/projectSessionState.ts +85 -0
- package/src/ui/session/sessionClientHost.ts +27 -0
- package/src/ui/session/tokenTransport.ts +76 -0
- package/src/ui/utils/activeAuthuser.ts +34 -76
- package/src/ui/utils/sessionHelpers.ts +0 -66
- package/src/utils/__tests__/deviceFlowSignIn.test.ts +243 -34
- package/src/utils/deviceFlowSignIn.ts +98 -21
- package/lib/commonjs/ui/components/accountMenuRows.js +0 -33
- package/lib/commonjs/ui/components/accountMenuRows.js.map +0 -1
- package/lib/commonjs/ui/hooks/useDeviceAccounts.js +0 -280
- package/lib/commonjs/ui/hooks/useDeviceAccounts.js.map +0 -1
- package/lib/commonjs/ui/hooks/useSessionSocket.js +0 -203
- package/lib/commonjs/ui/hooks/useSessionSocket.js.map +0 -1
- package/lib/commonjs/utils/silentGuardKey.js +0 -54
- package/lib/commonjs/utils/silentGuardKey.js.map +0 -1
- package/lib/module/ui/components/accountMenuRows.js +0 -29
- package/lib/module/ui/components/accountMenuRows.js.map +0 -1
- package/lib/module/ui/hooks/useDeviceAccounts.js +0 -276
- package/lib/module/ui/hooks/useDeviceAccounts.js.map +0 -1
- package/lib/module/ui/hooks/useSessionSocket.js +0 -199
- package/lib/module/ui/hooks/useSessionSocket.js.map +0 -1
- package/lib/module/utils/silentGuardKey.js +0 -49
- package/lib/module/utils/silentGuardKey.js.map +0 -1
- package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts +0 -34
- package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts +0 -133
- package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts +0 -14
- package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts.map +0 -1
- package/lib/typescript/commonjs/utils/silentGuardKey.d.ts +0 -31
- package/lib/typescript/commonjs/utils/silentGuardKey.d.ts.map +0 -1
- package/lib/typescript/module/ui/components/accountMenuRows.d.ts +0 -34
- package/lib/typescript/module/ui/components/accountMenuRows.d.ts.map +0 -1
- package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts +0 -133
- package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
- package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts +0 -14
- package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts.map +0 -1
- package/lib/typescript/module/utils/silentGuardKey.d.ts +0 -31
- package/lib/typescript/module/utils/silentGuardKey.d.ts.map +0 -1
- package/src/ui/components/accountMenuRows.ts +0 -48
- package/src/ui/hooks/useDeviceAccounts.ts +0 -344
- package/src/ui/hooks/useSessionSocket.ts +0 -233
- package/src/ui/utils/__tests__/activeAuthuser.test.ts +0 -75
- package/src/utils/__tests__/silentGuardKey.test.ts +0 -82
- package/src/utils/silentGuardKey.ts +0 -46
|
@@ -35,15 +35,20 @@ import {
|
|
|
35
35
|
import { toast } from '@oxyhq/bloom';
|
|
36
36
|
import { useAuthStore, type AuthState } from '../stores/authStore';
|
|
37
37
|
import { useShallow } from 'zustand/react/shallow';
|
|
38
|
-
import { useSessionSocket } from '../hooks/useSessionSocket';
|
|
39
38
|
import type { UseFollowHook } from '../hooks/useFollow.types';
|
|
40
39
|
import { useLanguageManagement } from '../hooks/useLanguageManagement';
|
|
41
40
|
import { useSessionManagement } from '../hooks/useSessionManagement';
|
|
42
|
-
import { useAuthOperations } from './hooks/useAuthOperations';
|
|
41
|
+
import { useAuthOperations, clearPriorSessionHintSafe } from './hooks/useAuthOperations';
|
|
43
42
|
import { useDeviceManagement } from '../hooks/useDeviceManagement';
|
|
44
43
|
import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
|
|
45
44
|
import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
|
|
46
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
readActiveAuthuser,
|
|
47
|
+
clearSignedOut,
|
|
48
|
+
isSilentRestoreSuppressed,
|
|
49
|
+
markSignedOut,
|
|
50
|
+
clearSsoBounceState,
|
|
51
|
+
} from '../utils/activeAuthuser';
|
|
47
52
|
import type { RouteName } from '../navigation/routes';
|
|
48
53
|
import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
|
|
49
54
|
import { useQueryClient } from '@tanstack/react-query';
|
|
@@ -51,11 +56,17 @@ import { clearQueryCache } from '../hooks/queryClient';
|
|
|
51
56
|
import { useAvatarPicker } from '../hooks/useAvatarPicker';
|
|
52
57
|
import { useAccountStore } from '../stores/accountStore';
|
|
53
58
|
import { logger as loggerUtil } from '@oxyhq/core';
|
|
54
|
-
import {
|
|
55
|
-
import { buildSilentGuardKey } from '../../utils/silentGuardKey';
|
|
59
|
+
import { isWebBrowser } from '../hooks/useWebSSO';
|
|
56
60
|
import { isCrossApexWeb, CrossApexDirectSignInError } from '../../utils/crossApex';
|
|
57
61
|
import { createInSessionRefreshHandler, startTokenRefreshScheduler } from './inSessionTokenRefresh';
|
|
58
|
-
import { mintSessionViaPerApexIframe
|
|
62
|
+
import { mintSessionViaPerApexIframe } from './silentSessionRestore';
|
|
63
|
+
import {
|
|
64
|
+
createSessionClient,
|
|
65
|
+
deviceStateToClientSessions,
|
|
66
|
+
activeSessionIdOf,
|
|
67
|
+
activeUserOf,
|
|
68
|
+
accountIdsOf,
|
|
69
|
+
} from '../session';
|
|
59
70
|
|
|
60
71
|
export interface OxyContextState {
|
|
61
72
|
user: User | null;
|
|
@@ -99,9 +110,10 @@ export interface OxyContextState {
|
|
|
99
110
|
/**
|
|
100
111
|
* Sign in with a username/email + password.
|
|
101
112
|
*
|
|
102
|
-
* Commits a successful session into context state through the SAME path
|
|
103
|
-
* /
|
|
104
|
-
* durably). Returns a discriminated result so the
|
|
113
|
+
* Commits a successful session into context state through the SAME path the
|
|
114
|
+
* SSO / silent-restore steps use (so `isAuthenticated` / `user` update and
|
|
115
|
+
* the session is persisted durably). Returns a discriminated result so the
|
|
116
|
+
* caller can branch on the
|
|
105
117
|
* two-factor-required case — which creates NO session; the caller completes
|
|
106
118
|
* the 2FA challenge with the returned `loginToken`.
|
|
107
119
|
*
|
|
@@ -171,14 +183,16 @@ export interface OxyContextState {
|
|
|
171
183
|
* Switch the active session INTO an account from the {@link accounts} graph
|
|
172
184
|
* (a managed org/project/bot, or an account shared with the caller).
|
|
173
185
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* it
|
|
180
|
-
*
|
|
181
|
-
*
|
|
186
|
+
* Uniform with every other account switch: if the account is already on
|
|
187
|
+
* this device's multi-account set, switches straight through the same
|
|
188
|
+
* server-authoritative `SessionClient.switchAccount()` path {@link switchSession}
|
|
189
|
+
* uses — no re-minting, no session churn. Only the FIRST switch into an
|
|
190
|
+
* account mints+plants a REAL session via `oxyServices.switchToAccount` and
|
|
191
|
+
* registers it into the device set (server-set httpOnly `oxy_rt_<authuser>`
|
|
192
|
+
* cookie), so it survives reload / `refresh-all` and appears in the device
|
|
193
|
+
* account list exactly like a device sign-in from then on. Either way,
|
|
194
|
+
* afterwards `user` IS the target account, every request authenticates as
|
|
195
|
+
* it, and the account graph + all React Query data are refreshed/invalidated.
|
|
182
196
|
*/
|
|
183
197
|
switchToAccount: (accountId: string) => Promise<void>;
|
|
184
198
|
refreshAccounts: () => Promise<void>;
|
|
@@ -191,8 +205,8 @@ const OxyContext = createContext<OxyContextState | null>(null);
|
|
|
191
205
|
* Result of {@link OxyContextState.signInWithPassword}.
|
|
192
206
|
*
|
|
193
207
|
* `'ok'` — the password was accepted and the resulting session has been
|
|
194
|
-
* committed into context state (the SAME path
|
|
195
|
-
* `isAuthenticated` / `user` are updated and the session is durably persisted;
|
|
208
|
+
* committed into context state (the SAME path SSO / silent-restore sessions
|
|
209
|
+
* use), so `isAuthenticated` / `user` are updated and the session is durably persisted;
|
|
196
210
|
* the caller can proceed (e.g. navigate into the app).
|
|
197
211
|
*
|
|
198
212
|
* `'2fa_required'` — the account has two-factor auth enabled, so NO session was
|
|
@@ -223,41 +237,6 @@ export interface OxyContextProviderProps {
|
|
|
223
237
|
onError?: (error: ApiError) => void;
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
/**
|
|
227
|
-
* Module-level run-once guard for the cold-boot `fedcm-silent` step.
|
|
228
|
-
*
|
|
229
|
-
* The FedCM silent step triggers a one-shot `navigator.credentials.get`
|
|
230
|
-
* handshake that must fire AT MOST ONCE per page load — otherwise a provider
|
|
231
|
-
* remount storm (route churn, StrictMode double-invoke, error-boundary
|
|
232
|
-
* recovery) becomes a credential request storm. A per-instance ref resets on
|
|
233
|
-
* every remount, so the guard must live at module scope. Keyed on
|
|
234
|
-
* `origin|baseURL` so two providers pointed at the same API from the same
|
|
235
|
-
* origin share one attempt; never cleared because only a fresh page load can
|
|
236
|
-
* change the central IdP session state, and a fresh page load starts a fresh
|
|
237
|
-
* module scope.
|
|
238
|
-
*
|
|
239
|
-
* This is a dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
|
|
240
|
-
* (which guards the post-boot INTERACTIVE button path) and never a core
|
|
241
|
-
* module-level singleton (that re-evaluates under Metro web bundling and the
|
|
242
|
-
* guard would not hold).
|
|
243
|
-
*/
|
|
244
|
-
const servicesSilentAttempted = new Set<string>();
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
|
|
248
|
-
*/
|
|
249
|
-
function silentColdBootKey(oxyServices: OxyServices): string {
|
|
250
|
-
// `buildSilentGuardKey` reads `window.location.origin` behind a guard that
|
|
251
|
-
// also verifies `window.location` exists. This is critical: it runs
|
|
252
|
-
// UNCONDITIONALLY at the top of `restoreSessionsFromStorage` (before the
|
|
253
|
-
// cold-boot try/catch) on EVERY platform, and React Native aliases a global
|
|
254
|
-
// `window` with NO `window.location`. Without that guard the read threw
|
|
255
|
-
// `Cannot read property 'origin' of undefined` on native, escaping the
|
|
256
|
-
// restore path so `markAuthResolved` never ran and stored-session restore was
|
|
257
|
-
// never reached.
|
|
258
|
-
return buildSilentGuardKey(() => oxyServices.getBaseURL?.());
|
|
259
|
-
}
|
|
260
|
-
|
|
261
240
|
/**
|
|
262
241
|
* Per-step fail-fast budget for the cold-boot silent iframe (`silentSignIn`
|
|
263
242
|
* against the per-apex `/auth/silent` host).
|
|
@@ -272,18 +251,6 @@ function silentColdBootKey(oxyServices: OxyServices): string {
|
|
|
272
251
|
*/
|
|
273
252
|
const SILENT_IFRAME_TIMEOUT = 2500;
|
|
274
253
|
|
|
275
|
-
/**
|
|
276
|
-
* Per-step fail-fast budget for the cold-boot refresh-cookie restore
|
|
277
|
-
* (`refreshAllSessions`).
|
|
278
|
-
*
|
|
279
|
-
* On a cross-domain RP the `Domain=oxy.so` refresh cookie never reaches
|
|
280
|
-
* `api.<apex>`, so this request returns no accounts (or stalls behind a slow
|
|
281
|
-
* endpoint) with no useful answer. As one cold-boot step it must not block the
|
|
282
|
-
* fall-through to the terminal `/sso` bounce. 3s bounds the wait while leaving
|
|
283
|
-
* ample headroom for a genuine first-party `*.oxy.so` rotation round-trip.
|
|
284
|
-
*/
|
|
285
|
-
const COOKIE_RESTORE_TIMEOUT = 3000;
|
|
286
|
-
|
|
287
254
|
/**
|
|
288
255
|
* Per-step fail-fast budget (ms) for the native shared-key cold-boot step
|
|
289
256
|
* (`signInWithSharedIdentity`).
|
|
@@ -301,15 +268,14 @@ const SHARED_KEY_SIGNIN_TIMEOUT = 8000;
|
|
|
301
268
|
/**
|
|
302
269
|
* HARD overall deadline (ms) for the entire cold-boot step loop —
|
|
303
270
|
* defense-in-depth so a single non-settling step can NEVER hang auth resolution
|
|
304
|
-
* forever (the production regression: a
|
|
305
|
-
* ignored its abort signal left
|
|
306
|
-
* `runColdBoot` never advanced to the terminal `/sso`
|
|
307
|
-
* indefinitely).
|
|
271
|
+
* forever (the production regression this guards against: a browser silent
|
|
272
|
+
* credential/iframe probe that ignored its own abort signal left a step's
|
|
273
|
+
* promise unsettled, so `runColdBoot` never advanced to the terminal `/sso`
|
|
274
|
+
* bounce and auth hung indefinitely).
|
|
308
275
|
*
|
|
309
276
|
* Every step ALREADY bounds its own network work (the stored-session bearer
|
|
310
|
-
* validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT
|
|
311
|
-
*
|
|
312
|
-
* plus its hard settle). On a healthy load the FIRST recovering step wins in a
|
|
277
|
+
* validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT` plus its hard
|
|
278
|
+
* settle). On a healthy load the FIRST recovering step wins in a
|
|
313
279
|
* single round-trip (1–3s) and the chain short-circuits long before this fires.
|
|
314
280
|
* This budget only trips when one of those per-step bounds regresses.
|
|
315
281
|
*
|
|
@@ -326,6 +292,23 @@ const SHARED_KEY_SIGNIN_TIMEOUT = 8000;
|
|
|
326
292
|
*/
|
|
327
293
|
const COLD_BOOT_OVERALL_DEADLINE = 20000;
|
|
328
294
|
|
|
295
|
+
/**
|
|
296
|
+
* How long cold boot WAITS for the post-ladder SessionClient handoff
|
|
297
|
+
* (`addCurrentAccount` + `start` + `syncFromClient`) before it resolves auth
|
|
298
|
+
* and stops blocking. Once a ladder step planted a token the user is already
|
|
299
|
+
* authenticated — the handoff only populates the multi-account set and the
|
|
300
|
+
* server-authoritative active account, and those also arrive via the socket
|
|
301
|
+
* subscription wired in `useEffect`. So on a slow/unresponsive backend we stop
|
|
302
|
+
* AWAITING the handoff here (it keeps running in the background and projects
|
|
303
|
+
* when it lands) rather than delaying `markAuthResolved` — otherwise the two
|
|
304
|
+
* sequential `HttpService`-bounded REST calls could push auth resolution up to
|
|
305
|
+
* ~10s past the ladder's own budget, reintroducing the exact spinner-stall
|
|
306
|
+
* `COLD_BOOT_OVERALL_DEADLINE` exists to prevent. On the normal fast path the
|
|
307
|
+
* handoff completes well within this, so the correct account shows with no
|
|
308
|
+
* flash.
|
|
309
|
+
*/
|
|
310
|
+
const SESSION_HANDOFF_DEADLINE = 6000;
|
|
311
|
+
|
|
329
312
|
/**
|
|
330
313
|
* Per-session soft timeout (ms) for the parallel stored-session validation in
|
|
331
314
|
* `restoreStoredSession`. Each `validateSession` call races against this timer
|
|
@@ -606,9 +589,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
606
589
|
// hooks below. But it is `null` for a brief window after mount while
|
|
607
590
|
// `createPlatformStorage()` resolves (a microtask on web; a dynamic
|
|
608
591
|
// `import()` on native). Any persistence path that fires during that window
|
|
609
|
-
// — e.g. an interactive
|
|
610
|
-
// read `storage === null` and SILENTLY skip writing the session,
|
|
611
|
-
// user signed-in in-memory but with nothing to restore on
|
|
592
|
+
// — e.g. an interactive password sign-in the instant the screen mounts —
|
|
593
|
+
// would read `storage === null` and SILENTLY skip writing the session,
|
|
594
|
+
// leaving the user signed-in in-memory but with nothing to restore on
|
|
595
|
+
// reload.
|
|
612
596
|
//
|
|
613
597
|
// To make persistence robust regardless of timing we ALSO expose the storage
|
|
614
598
|
// as an awaitable promise (`getReadyStorage`). Persistence code awaits the
|
|
@@ -716,10 +700,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
716
700
|
setActiveSessionId,
|
|
717
701
|
updateSessions,
|
|
718
702
|
switchSession,
|
|
719
|
-
refreshSessions,
|
|
720
703
|
clearSessionState,
|
|
721
704
|
saveActiveSessionId,
|
|
722
|
-
trackRemovedSession,
|
|
723
705
|
} = useSessionManagement({
|
|
724
706
|
oxyServices,
|
|
725
707
|
storage,
|
|
@@ -735,6 +717,147 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
735
717
|
queryClient,
|
|
736
718
|
});
|
|
737
719
|
|
|
720
|
+
// Session-sync integration layer (Fase 3-A -> 3-B). Built ONCE per
|
|
721
|
+
// `oxyServices` instance via a lazy ref (mirrors the `oxyServicesRef`
|
|
722
|
+
// pattern above) so the underlying `SessionClient` — and its socket
|
|
723
|
+
// connection, once started — is never recreated across renders.
|
|
724
|
+
//
|
|
725
|
+
// ADDITIVE + INERT for this task: `client.start()` is NOT called here (that
|
|
726
|
+
// is Task 2's job, which also retires the 8-step cold boot below). Until
|
|
727
|
+
// then `client.getState()` never advances past `null`, so `syncFromClient`
|
|
728
|
+
// is a guaranteed no-op in production — the existing cold-boot-driven state
|
|
729
|
+
// remains the sole authority. The wiring exists so the projection can be
|
|
730
|
+
// exercised in isolation against a controlled client before the authority
|
|
731
|
+
// flip.
|
|
732
|
+
const sessionClientPairRef = useRef<ReturnType<typeof createSessionClient> | null>(null);
|
|
733
|
+
if (!sessionClientPairRef.current) {
|
|
734
|
+
sessionClientPairRef.current = createSessionClient(oxyServices);
|
|
735
|
+
}
|
|
736
|
+
const { client: sessionClient, host: sessionClientHost } = sessionClientPairRef.current;
|
|
737
|
+
|
|
738
|
+
// Cold-boot registration dedup (Task 5). `handleWebSSOSession` (declared
|
|
739
|
+
// below) is the single commit funnel for every web ladder step that mints a
|
|
740
|
+
// session (`sso-return`, `shared-key-signin`, `silent-iframe`) — it registers
|
|
741
|
+
// the recovered account into the device set
|
|
742
|
+
// itself (`sessionClient.addCurrentAccount()`). The cold-boot post-ladder
|
|
743
|
+
// handoff ALSO registers, but only as a FALLBACK for the `stored-session`
|
|
744
|
+
// step, which commits through a completely different path
|
|
745
|
+
// (`switchSessionRef`) and never touches `sessionClient` on its own. This
|
|
746
|
+
// ref lets the handoff detect "did a ladder step already register this
|
|
747
|
+
// boot?" and skip a redundant second `POST /session/device/add`. Reset at
|
|
748
|
+
// the start of every cold-boot pass.
|
|
749
|
+
const registeredDuringBootRef = useRef(false);
|
|
750
|
+
|
|
751
|
+
// Projects `client.getState()` onto the exposed `sessions` / `activeSessionId`
|
|
752
|
+
// / `user` via the SAME setters the existing cold-boot/session-management
|
|
753
|
+
// paths use. This is the SOLE authority for both a locally-initiated mutation
|
|
754
|
+
// (switch/logout resolving here) AND a REMOTELY-pushed `session_state` (the
|
|
755
|
+
// `device:<deviceId>` socket owned by `client.start()`, subscribed to below) —
|
|
756
|
+
// there is no per-domain `useSessionSocket` anymore.
|
|
757
|
+
//
|
|
758
|
+
// GAP FIX (Task 4): when the pushed/bootstrapped state reports ZERO device
|
|
759
|
+
// accounts, this device has been fully signed out — either by the LOCAL
|
|
760
|
+
// `logout()`/`logoutAll()` mutation (which also runs its own explicit
|
|
761
|
+
// cleanup) or, just as importantly, by a REMOTE actor (another tab/device
|
|
762
|
+
// removing the last session, or an admin revoking every device account)
|
|
763
|
+
// pushing that same empty state over the socket. The prior per-domain
|
|
764
|
+
// `useSessionSocket` forced a local sign-out in this situation
|
|
765
|
+
// (`triggerLocalSignOut`); without an equivalent here a remote full sign-out
|
|
766
|
+
// would leave `user`/`isAuthenticated`/the bearer token stale until the next
|
|
767
|
+
// 401. Route through the SAME `clearSessionState()` a local full sign-out
|
|
768
|
+
// uses (authStore reset, token clear, query-cache clear, storage clear) —
|
|
769
|
+
// idempotent with the local-logout caller's own explicit call.
|
|
770
|
+
//
|
|
771
|
+
// DELIBERATE-SIGN-OUT GUARD (Task 5): a zero-account state — however it
|
|
772
|
+
// arrived — is just as terminal as a local `logout()`/`logoutAll()`, so it
|
|
773
|
+
// sets the SAME durable "deliberately signed out" flag (web-only,
|
|
774
|
+
// best-effort) that those two callers set directly. Without this, a remote
|
|
775
|
+
// full wipe would leave the flag unset, and the `silent-iframe` cold-boot
|
|
776
|
+
// step could silently re-mint a session from a still-live per-apex
|
|
777
|
+
// `fedcm_session` cookie right after this device was authoritatively signed
|
|
778
|
+
// out everywhere.
|
|
779
|
+
//
|
|
780
|
+
// REMOTE-SIGN-OUT SUPPRESSION FIX (review H1): the terminal `sso-bounce`
|
|
781
|
+
// cold-boot step is gated on the durable prior-session hint
|
|
782
|
+
// (`allowSsoBounce` = `hasPriorSession || hasLocalSession`), NOT on the
|
|
783
|
+
// deliberately-signed-out flag set above. Leaving that hint in place after a
|
|
784
|
+
// REMOTE full sign-out means the next reload still performs one terminal
|
|
785
|
+
// `/sso` establish bounce, which can silently re-mint a session from a
|
|
786
|
+
// still-live central `fedcm_session` — signing the user back in right after
|
|
787
|
+
// they were signed out everywhere. Clear the SAME cleanup set the LOCAL
|
|
788
|
+
// `logout()`/`logoutAll()` paths use (`useAuthOperations.ts`) — SSO bounce
|
|
789
|
+
// state + the prior-session hint — so a remote sign-out is indistinguishable
|
|
790
|
+
// from a local one to the next cold boot.
|
|
791
|
+
const syncFromClient = useCallback(async (): Promise<void> => {
|
|
792
|
+
const state = sessionClient.getState();
|
|
793
|
+
if (state === null) {
|
|
794
|
+
// INERT: no session state has been bootstrapped (client.start() has not
|
|
795
|
+
// run). Never overwrite the existing cold-boot-driven state.
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (state.accounts.length === 0) {
|
|
799
|
+
sessionClientHost.setCurrentAccountId(null);
|
|
800
|
+
if (isWebBrowser()) {
|
|
801
|
+
markSignedOut();
|
|
802
|
+
}
|
|
803
|
+
clearSsoBounceState();
|
|
804
|
+
clearPriorSessionHintSafe(clearPriorSessionHint, logger);
|
|
805
|
+
await clearSessionState();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
// LAST-WRITE-WINS GUARD (review I3): `syncFromClient` is called
|
|
809
|
+
// concurrently — once per socket `notify()` push AND once per direct
|
|
810
|
+
// mutation call — and each invocation captures `state` before an async
|
|
811
|
+
// profile fetch (`getUsersByIds`). `SessionClient`'s own state is
|
|
812
|
+
// monotonic by revision, but this projection previously was not: a
|
|
813
|
+
// SLOWER, OLDER fetch resolving AFTER a newer one would still apply its
|
|
814
|
+
// now-stale captured `state`, clobbering `user`/`sessions`/
|
|
815
|
+
// `activeSessionId` back to an outdated account. Capture the revision
|
|
816
|
+
// this fetch is FOR, then after the await, re-read the client's current
|
|
817
|
+
// state and bail if it has since moved past the captured revision — a
|
|
818
|
+
// fresher call has already applied (or will apply) the current truth.
|
|
819
|
+
const capturedRevision = state.revision;
|
|
820
|
+
const ids = accountIdsOf(state);
|
|
821
|
+
let users: User[] = [];
|
|
822
|
+
try {
|
|
823
|
+
users = ids.length > 0 ? await oxyServices.getUsersByIds(ids) : [];
|
|
824
|
+
} catch (fetchError) {
|
|
825
|
+
// Invoked fire-and-forget from the socket `notify()` subscription, so a
|
|
826
|
+
// rejected profile fetch would surface as an unhandled rejection. Log and
|
|
827
|
+
// bail; the next `notify()`/mutation will re-project the current truth.
|
|
828
|
+
logger('Failed to resolve account profiles during syncFromClient', fetchError);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
const latest = sessionClient.getState();
|
|
832
|
+
if (!latest || latest.revision !== capturedRevision) {
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const usersById = new Map(users.map((resolvedUser) => [resolvedUser.id, resolvedUser]));
|
|
836
|
+
updateSessions(deviceStateToClientSessions(latest, usersById));
|
|
837
|
+
setActiveSessionId(activeSessionIdOf(latest));
|
|
838
|
+
const activeUser = activeUserOf(latest, usersById);
|
|
839
|
+
if (activeUser) {
|
|
840
|
+
loginSuccess(activeUser);
|
|
841
|
+
}
|
|
842
|
+
sessionClientHost.setCurrentAccountId(latest.activeAccountId);
|
|
843
|
+
}, [
|
|
844
|
+
oxyServices,
|
|
845
|
+
sessionClient,
|
|
846
|
+
sessionClientHost,
|
|
847
|
+
updateSessions,
|
|
848
|
+
setActiveSessionId,
|
|
849
|
+
loginSuccess,
|
|
850
|
+
clearSessionState,
|
|
851
|
+
clearPriorSessionHint,
|
|
852
|
+
logger,
|
|
853
|
+
]);
|
|
854
|
+
|
|
855
|
+
useEffect(() => {
|
|
856
|
+
return sessionClient.subscribe(() => {
|
|
857
|
+
void syncFromClient();
|
|
858
|
+
});
|
|
859
|
+
}, [sessionClient, syncFromClient]);
|
|
860
|
+
|
|
738
861
|
const {
|
|
739
862
|
signIn,
|
|
740
863
|
logout,
|
|
@@ -742,7 +865,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
742
865
|
} = useAuthOperations({
|
|
743
866
|
oxyServices,
|
|
744
867
|
storage,
|
|
745
|
-
sessions,
|
|
746
868
|
activeSessionId,
|
|
747
869
|
setActiveSessionId,
|
|
748
870
|
updateSessions,
|
|
@@ -750,6 +872,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
750
872
|
clearSessionState,
|
|
751
873
|
clearPriorSessionHint,
|
|
752
874
|
switchSession,
|
|
875
|
+
sessionClient,
|
|
876
|
+
syncFromClient,
|
|
753
877
|
applyLanguagePreference,
|
|
754
878
|
onAuthStateChange,
|
|
755
879
|
onError,
|
|
@@ -881,7 +1005,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
881
1005
|
// init. Callers MUST invoke this BEFORE any work that can trigger a route
|
|
882
1006
|
// navigation (`onAuthStateChange`) — navigation can interrupt a still-pending
|
|
883
1007
|
// async write, which is exactly what once left `session_ids` empty after a
|
|
884
|
-
// successful sign-in. Shared by the
|
|
1008
|
+
// successful sign-in. Shared by the SSO/silent-restore path and the cold-boot
|
|
885
1009
|
// refresh-cookie restore so both land the same durable record.
|
|
886
1010
|
const persistSessionDurably = useCallback(async (sessionId: string): Promise<void> => {
|
|
887
1011
|
const readyStorage = await getReadyStorage();
|
|
@@ -900,7 +1024,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
900
1024
|
// A session is now durably committed — set the returning-user hint so a
|
|
901
1025
|
// future cold boot whose local session has lapsed still gets ONE `/sso`
|
|
902
1026
|
// establish bounce (see `markPriorSessionHint`). Every web commit path
|
|
903
|
-
// (
|
|
1027
|
+
// (silent iframe / SSO return / password / cookie restore) funnels
|
|
904
1028
|
// through here, so this is the single chokepoint for the hint.
|
|
905
1029
|
await readyStorage.setItem(storageKeys.priorSession, '1');
|
|
906
1030
|
}, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds, storageKeys.priorSession]);
|
|
@@ -919,10 +1043,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
919
1043
|
// Idempotent and monotonic via `authResolvedRef`: the first call wins and the
|
|
920
1044
|
// setters fire at most once, so the restore `finally` backstop becomes a no-op
|
|
921
1045
|
// once a commit site has already marked resolution. Called from EVERY place a
|
|
922
|
-
// user is actually committed (the
|
|
923
|
-
// `handleWebSSOSession
|
|
924
|
-
//
|
|
925
|
-
//
|
|
1046
|
+
// user is actually committed (the iframe/SSO path
|
|
1047
|
+
// `handleWebSSOSession` and the stored-session path) so the common reload
|
|
1048
|
+
// case unblocks the loading gate without sitting behind the remaining
|
|
1049
|
+
// (now-skipped) cold-boot steps.
|
|
926
1050
|
const markAuthResolved = useCallback(() => {
|
|
927
1051
|
if (authResolvedRef.current) {
|
|
928
1052
|
return;
|
|
@@ -935,104 +1059,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
935
1059
|
markAuthResolvedRef.current = markAuthResolved;
|
|
936
1060
|
|
|
937
1061
|
// `handleWebSSOSession` is declared further down (it depends on values that
|
|
938
|
-
// are only available there). The
|
|
1062
|
+
// are only available there). The iframe/SSO cold-boot steps need to commit
|
|
939
1063
|
// a recovered session through it, so we route the call through a ref that is
|
|
940
1064
|
// populated once the callback exists. The ref is assigned synchronously on
|
|
941
1065
|
// every render before the cold-boot effect can fire (the effect is gated on
|
|
942
1066
|
// `storage` + `initialized`, both of which settle after first render).
|
|
943
1067
|
const handleWebSSOSessionRef = useRef<((session: SessionLoginResponse) => Promise<void>) | null>(null);
|
|
944
1068
|
|
|
945
|
-
// Cold-boot session restore via the secure refresh cookies (web only).
|
|
946
|
-
//
|
|
947
|
-
// Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
|
|
948
|
-
// `credentials: 'include'`. The server rotates every device-local
|
|
949
|
-
// `oxy_rt_${authuser}` cookie in parallel and returns one entry per valid
|
|
950
|
-
// account (Google-style multi-account).
|
|
951
|
-
//
|
|
952
|
-
// Active-account selection: the persisted `oxy_active_authuser` slot index
|
|
953
|
-
// wins when it matches a returned account; otherwise the lowest `authuser`
|
|
954
|
-
// is picked. JS never sees the refresh cookies (httpOnly).
|
|
955
|
-
//
|
|
956
|
-
// Returns `true` when at least one session was restored (caller short-
|
|
957
|
-
// circuits the bearer path); `false` on no signed-in accounts / any failure
|
|
958
|
-
// (caller proceeds unauthenticated through the existing flow — nothing is
|
|
959
|
-
// cleared).
|
|
960
|
-
const restoreViaRefreshCookie = useCallback(async (): Promise<boolean> => {
|
|
961
|
-
if (!isWebBrowser()) {
|
|
962
|
-
return false;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
let snapshot;
|
|
966
|
-
try {
|
|
967
|
-
// Bound the refresh so a cross-domain/stalled call cannot hang the cold
|
|
968
|
-
// boot in front of the terminal `/sso` bounce (see COOKIE_RESTORE_TIMEOUT).
|
|
969
|
-
snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_RESTORE_TIMEOUT });
|
|
970
|
-
} catch (fetchError) {
|
|
971
|
-
// Offline / network error — fall through to the cached/stored-session flow.
|
|
972
|
-
if (__DEV__) {
|
|
973
|
-
loggerUtil.debug('Refresh-all cookie restore network error (expected when offline)', { component: 'OxyContext', method: 'restoreViaRefreshCookie' }, fetchError as unknown);
|
|
974
|
-
}
|
|
975
|
-
return false;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
if (snapshot.accounts.length === 0) {
|
|
979
|
-
return false;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Pick the active account: persisted authuser if it still matches a returned
|
|
983
|
-
// account, otherwise the lowest authuser (deterministic). The server has
|
|
984
|
-
// already sorted ascending so [0] is the lowest.
|
|
985
|
-
const activeAccount = selectActiveRefreshAccount(snapshot.accounts, readActiveAuthuser());
|
|
986
|
-
|
|
987
|
-
// Plant the active access token. Sibling accounts' access tokens stay in
|
|
988
|
-
// the snapshot (the chooser can drive a per-account refresh via
|
|
989
|
-
// `refreshTokenViaCookie({authuser})` on switch).
|
|
990
|
-
oxyServices.httpService.setTokens(activeAccount.accessToken);
|
|
991
|
-
|
|
992
|
-
// Fetch the full user with the freshly planted token. The refresh-all
|
|
993
|
-
// payload includes a minimal user shape (id, username, name, avatar,
|
|
994
|
-
// email, color) — sufficient for the chooser but the auth store wants the
|
|
995
|
-
// canonical User document for downstream rendering.
|
|
996
|
-
let fullUser: User;
|
|
997
|
-
try {
|
|
998
|
-
fullUser = await oxyServices.getCurrentUser();
|
|
999
|
-
} catch (userError) {
|
|
1000
|
-
// Token planted but profile fetch failed (e.g. transient network). Do
|
|
1001
|
-
// not claim a restored session; fall through so the stored-session flow
|
|
1002
|
-
// can retry. Leave the planted token in place — it is valid and harmless.
|
|
1003
|
-
if (__DEV__) {
|
|
1004
|
-
loggerUtil.debug('Refresh-all cookie restore: getCurrentUser failed', { component: 'OxyContext', method: 'restoreViaRefreshCookie' }, userError as unknown);
|
|
1005
|
-
}
|
|
1006
|
-
return false;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
// Build a ClientSession per returned account so the multi-session store
|
|
1010
|
-
// reflects every device-local slot, not just the active one. The active
|
|
1011
|
-
// account is flagged `isCurrent: true`.
|
|
1012
|
-
const now = new Date();
|
|
1013
|
-
const clientSessions: ClientSession[] = snapshot.accounts.map((account) => ({
|
|
1014
|
-
sessionId: account.sessionId,
|
|
1015
|
-
deviceId: '',
|
|
1016
|
-
expiresAt: account.expiresAt || new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
|
|
1017
|
-
lastActive: now.toISOString(),
|
|
1018
|
-
userId: account.user?.id,
|
|
1019
|
-
isCurrent: account.sessionId === activeAccount.sessionId,
|
|
1020
|
-
authuser: account.authuser,
|
|
1021
|
-
}));
|
|
1022
|
-
|
|
1023
|
-
updateSessionsRef.current(clientSessions, { merge: true });
|
|
1024
|
-
setActiveSessionIdRef.current(activeAccount.sessionId);
|
|
1025
|
-
writeActiveAuthuser(activeAccount.authuser);
|
|
1026
|
-
await persistSessionDurably(activeAccount.sessionId);
|
|
1027
|
-
|
|
1028
|
-
loginSuccessRef.current(fullUser);
|
|
1029
|
-
// A session is now committed — unblock the auth-resolution gate immediately
|
|
1030
|
-
// rather than waiting for `runColdBoot` to return (idempotent).
|
|
1031
|
-
markAuthResolvedRef.current();
|
|
1032
|
-
onAuthStateChangeRef.current?.(fullUser);
|
|
1033
|
-
return true;
|
|
1034
|
-
}, [oxyServices, persistSessionDurably]);
|
|
1035
|
-
|
|
1036
1069
|
// Native (and offline) stored-session restore — the ONLY restore path that
|
|
1037
1070
|
// runs on React Native, and the web fallback when no cross-domain step won.
|
|
1038
1071
|
//
|
|
@@ -1048,9 +1081,28 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1048
1081
|
const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
|
|
1049
1082
|
const storedSessionIdsFromStorage: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
|
|
1050
1083
|
let storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
|
|
1084
|
+
// OPTIONAL HINT ONLY (no longer a gate). Nothing writes the active-`authuser`
|
|
1085
|
+
// marker since the SessionClient cutover (`writeActiveAuthuser` was deleted),
|
|
1086
|
+
// so it is `null` on every fresh install; when a legacy value is present it
|
|
1087
|
+
// is used purely to backfill `clientSession.authuser` for the active session
|
|
1088
|
+
// below. It MUST NOT decide whether restore is attempted — see the guard.
|
|
1051
1089
|
const storedActiveAuthuser = isWebBrowser() ? readActiveAuthuser() : null;
|
|
1052
1090
|
|
|
1053
|
-
|
|
1091
|
+
// On web with no in-memory bearer, bail ONLY when there is genuinely nothing
|
|
1092
|
+
// to restore — neither a stored active id NOR any stored session ids. A
|
|
1093
|
+
// surviving `sessionIds` list (e.g. the active-id key was removed after a
|
|
1094
|
+
// server-side revocation while sibling sessions remain) MUST still be
|
|
1095
|
+
// validated over the network and have a valid session elected as active
|
|
1096
|
+
// below. The old guard also required the deleted `activeAuthuser` marker,
|
|
1097
|
+
// which since the cutover is never written — that made EVERY web reload
|
|
1098
|
+
// whose local bearer had lapsed bail before any validation. Native always
|
|
1099
|
+
// proceeds (it plants the shared KeyManager bearer next).
|
|
1100
|
+
if (
|
|
1101
|
+
isWebBrowser() &&
|
|
1102
|
+
!oxyServices.getAccessToken() &&
|
|
1103
|
+
storedActiveSessionId === null &&
|
|
1104
|
+
storedSessionIdsFromStorage.length === 0
|
|
1105
|
+
) {
|
|
1054
1106
|
return false;
|
|
1055
1107
|
}
|
|
1056
1108
|
|
|
@@ -1151,20 +1203,56 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1151
1203
|
} catch (switchError) {
|
|
1152
1204
|
// Silently handle expected errors (invalid sessions, timeouts, network issues)
|
|
1153
1205
|
if (isInvalidSessionError(switchError)) {
|
|
1206
|
+
// The stored active id was rejected server-side. Clear it and drop the
|
|
1207
|
+
// dead session from the local view, then FALL THROUGH to the election
|
|
1208
|
+
// below to promote a surviving valid session — a stale/removed active
|
|
1209
|
+
// marker must not strand the other still-valid accounts.
|
|
1154
1210
|
await storage.removeItem(storageKeys.activeSessionId);
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1211
|
+
validSessions = validSessions.filter((session) => session.sessionId !== storedActiveSessionId);
|
|
1212
|
+
updateSessionsRef.current(validSessions, {
|
|
1213
|
+
merge: false,
|
|
1214
|
+
preserveSessionIds: unvalidatedSessionIds,
|
|
1215
|
+
});
|
|
1159
1216
|
// Don't log expected session errors during restoration
|
|
1160
1217
|
} else if (isTimeoutOrNetworkError(switchError)) {
|
|
1161
|
-
// Timeout/network
|
|
1218
|
+
// Timeout/network — the stored active session may still be valid; we
|
|
1219
|
+
// just could not confirm it. Do NOT elect a different session (that
|
|
1220
|
+
// could switch accounts under the user); leave restore to a retry / the
|
|
1221
|
+
// remaining cold-boot steps.
|
|
1162
1222
|
if (__DEV__) {
|
|
1163
1223
|
loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, switchError as unknown);
|
|
1164
1224
|
}
|
|
1225
|
+
return false;
|
|
1165
1226
|
} else {
|
|
1166
|
-
// Only log unexpected errors
|
|
1227
|
+
// Only log unexpected errors, and do not elect on an ambiguous failure.
|
|
1167
1228
|
logger('Active session validation error', switchError);
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Election fallback. Reached when there is no usable stored active id —
|
|
1235
|
+
// either it was never persisted (e.g. removed after a server-side revocation
|
|
1236
|
+
// while sibling sessions survived) or it was just rejected as invalid above.
|
|
1237
|
+
// Promote the FIRST validated session to active and commit it through the
|
|
1238
|
+
// same `switchSession` path. The server owns which account is truly active,
|
|
1239
|
+
// so any valid session is a safe provisional active — the post-restore
|
|
1240
|
+
// SessionClient handoff reconciles the real active account immediately after.
|
|
1241
|
+
const electedSession = validSessions[0];
|
|
1242
|
+
if (electedSession) {
|
|
1243
|
+
try {
|
|
1244
|
+
await switchSessionRef.current(electedSession.sessionId);
|
|
1245
|
+
await storage.setItem(storageKeys.activeSessionId, electedSession.sessionId);
|
|
1246
|
+
await storage.setItem(storageKeys.priorSession, '1');
|
|
1247
|
+
markAuthResolvedRef.current();
|
|
1248
|
+
return true;
|
|
1249
|
+
} catch (electionError) {
|
|
1250
|
+
// Activation requires an in-memory bearer on web; without one (a plain
|
|
1251
|
+
// web reload) the switch throws and recovery defers to the cross-domain
|
|
1252
|
+
// cold-boot steps. Invalid/timeout/network are expected here — surface
|
|
1253
|
+
// only genuinely unexpected failures.
|
|
1254
|
+
if (!isInvalidSessionError(electionError) && !isTimeoutOrNetworkError(electionError)) {
|
|
1255
|
+
logger('Elected session activation error', electionError);
|
|
1168
1256
|
}
|
|
1169
1257
|
}
|
|
1170
1258
|
}
|
|
@@ -1250,21 +1338,31 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1250
1338
|
// web-only step is gated by `isWebBrowser()`, so on native ONLY
|
|
1251
1339
|
// `stored-session` runs.
|
|
1252
1340
|
//
|
|
1253
|
-
// Order (web): SSO return → stored session →
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1341
|
+
// Order (web): SSO return → stored session → shared-key sign-in (native
|
|
1342
|
+
// only, disabled here) → silent iframe (per-apex, the durable reload path)
|
|
1343
|
+
// → SSO bounce (terminal). This is a pure TOKEN-ACQUISITION ladder — it
|
|
1344
|
+
// mints the first per-domain access token by whichever means recovers one
|
|
1345
|
+
// fastest. Once a token is acquired (or the ladder exhausts), the
|
|
1346
|
+
// SERVER-authoritative `SessionClient` takes over: `addCurrentAccount` +
|
|
1347
|
+
// `start` bootstrap the device session set and multi-account/active-account
|
|
1348
|
+
// state from `GET /session/device/state`, so WHICH account is active is
|
|
1349
|
+
// decided server-side, not by a client-persisted slot. There is no oxy_rt
|
|
1350
|
+
// refresh-cookie restore step in this ladder, and no FedCM step — FedCM was
|
|
1351
|
+
// removed from the client sign-in/cold-boot path entirely (Chrome-only, and
|
|
1352
|
+
// its fast/silent failure mode combined with a caller's auth-guard effect
|
|
1353
|
+
// could re-trigger sign-in in a tight loop; see `CrossDomainAuth`'s doc
|
|
1354
|
+
// comment in `@oxyhq/core`).
|
|
1256
1355
|
//
|
|
1257
|
-
// LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
// `sso-bounce` never fires (no flash, no loop).
|
|
1356
|
+
// LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect
|
|
1357
|
+
// `silent-iframe` probe. On a normal reload the local bearer validates in
|
|
1358
|
+
// one round-trip and wins, so `runColdBoot` short-circuits and never sits
|
|
1359
|
+
// through that probe's timeout. `sso-return` MUST stay first — it consumes
|
|
1360
|
+
// the URL fragment before anything can strip it. On a first visit with no
|
|
1361
|
+
// local session, `stored-session` skips and the cross-domain fallback chain
|
|
1362
|
+
// (silent-iframe → sso-bounce) runs exactly as before; the per-apex silent
|
|
1363
|
+
// iframe still restores a durable cross-domain session on reload WITHOUT a
|
|
1364
|
+
// top-level bounce, so when it wins `sso-bounce` never fires (no flash, no
|
|
1365
|
+
// loop).
|
|
1268
1366
|
// Order (native): stored session only (every web-only step is disabled
|
|
1269
1367
|
// off-browser).
|
|
1270
1368
|
const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
|
|
@@ -1273,19 +1371,19 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1273
1371
|
}
|
|
1274
1372
|
|
|
1275
1373
|
setTokenReady(false);
|
|
1374
|
+
// Fresh per-boot flag — see the declaration comment above `sessionClient`.
|
|
1375
|
+
registeredDuringBootRef.current = false;
|
|
1276
1376
|
|
|
1277
1377
|
const commitWebSession = handleWebSSOSessionRef.current;
|
|
1278
|
-
const silentKey = silentColdBootKey(oxyServices);
|
|
1279
|
-
const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
|
|
1280
1378
|
|
|
1281
1379
|
// FIX-B precondition flag: set true the instant the (now-earlier)
|
|
1282
|
-
// `stored-session` step recovers a local bearer session. The slow
|
|
1283
|
-
//
|
|
1284
|
-
// so
|
|
1380
|
+
// `stored-session` step recovers a local bearer session. The slow
|
|
1381
|
+
// web-only `silent-iframe` probe is `enabled` on `!storedSessionRestored`
|
|
1382
|
+
// so it is explicitly skipped once a local session won. `runColdBoot`
|
|
1285
1383
|
// already short-circuits on the first `{kind:'session'}`, so on a winning
|
|
1286
|
-
// reload
|
|
1384
|
+
// reload that `enabled` body is never even reached — this flag makes the
|
|
1287
1385
|
// intent explicit and is redundant-safe. On a first-visit-no-local-session,
|
|
1288
|
-
// `stored-session` skips, this stays false, and the
|
|
1386
|
+
// `stored-session` skips, this stays false, and the probe runs as before.
|
|
1289
1387
|
let storedSessionRestored = false;
|
|
1290
1388
|
|
|
1291
1389
|
// FIX-B smart-gate input: has this device/app EVER had a signed-in Oxy
|
|
@@ -1309,52 +1407,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1309
1407
|
}
|
|
1310
1408
|
}
|
|
1311
1409
|
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
// `
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
// run cookie-restore BEFORE those probes (and disable the later duplicate).
|
|
1323
|
-
//
|
|
1324
|
-
// Read ONCE here (synchronously usable by the step `enabled` gates below).
|
|
1325
|
-
// It is non-null ONLY on first-party web apps that persist the slot. Both the
|
|
1326
|
-
// device sign-in/switch paths AND `handleWebSSOSession` (FedCM/SSO/credentials
|
|
1327
|
-
// commit funnel) now persist it — but only when the session genuinely joined
|
|
1328
|
-
// the device set: `establishDeviceRefreshSlot` returns an `authuser` ONLY on
|
|
1329
|
-
// first-party web (same registrable apex as the API), so a cross-apex RP never
|
|
1330
|
-
// writes a phantom slot and native has no localStorage. Both therefore keep
|
|
1331
|
-
// their existing order untouched. The earlier `stored-session` step still runs
|
|
1332
|
-
// first (FIX-A latency), and when a slot exists but its cookies have all
|
|
1333
|
-
// lapsed, cookie-restore simply returns no accounts and the chain falls through
|
|
1334
|
-
// to the cross-domain fallbacks exactly as before.
|
|
1335
|
-
const prioritizeMultiAccount = isWebBrowser() && readActiveAuthuser() !== null;
|
|
1336
|
-
|
|
1337
|
-
// DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", any
|
|
1338
|
-
// credential that can silently re-mint a session may still be live on the
|
|
1339
|
-
// next reload — the central IdP session (FedCM credential association /
|
|
1340
|
-
// per-apex `fedcm_session` cookie) AND the device refresh cookies. Since
|
|
1341
|
-
// PR #455 the PRIMARY web session also joins the `oxy_rt_<authuser>` device
|
|
1342
|
-
// set, so the un-gated cookie-restore steps would `refresh-all` that still-
|
|
1343
|
-
// present cookie and sign the user back in without intent — exactly the
|
|
1344
|
-
// "sign-out is silently undone" regression. So this flag gates EVERY
|
|
1345
|
-
// AUTOMATIC silent-restore cold-boot step: `stored-session`,
|
|
1346
|
-
// `cookie-restore-active`, `fedcm-silent`, `silent-iframe`, and
|
|
1347
|
-
// `cookie-restore`. The gate — not a cookie wipe — is the authority: the
|
|
1348
|
-
// account may stay "known" for a fast deliberate re-sign-in, but no step
|
|
1349
|
-
// restores it silently while the flag is set. Read the durable flag ONCE here
|
|
1350
|
-
// (synchronously usable by every step `enabled` gate). Any deliberate sign-in
|
|
1351
|
-
// (password, FedCM, account switch, device claim) clears it, so there is no
|
|
1352
|
-
// "stuck signed out" state. The `sso-bounce` step needs no extra gate: it is
|
|
1353
|
-
// already self-suppressed after sign-out (its `hasPriorSession` hint is
|
|
1354
|
-
// cleared). `sso-return` is NOT gated — it commits the result of a deliberate
|
|
1355
|
-
// top-level `/sso` bounce the user just initiated. `shared-key-signin` is
|
|
1356
|
-
// native-only and the flag never sets on native (`markSignedOut` no-ops
|
|
1357
|
-
// off-web), so its gate would be moot; it is left untouched.
|
|
1410
|
+
// DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", the
|
|
1411
|
+
// per-apex `fedcm_session` cookie can still be live, so the AUTOMATIC
|
|
1412
|
+
// `silent-iframe` step below would re-mint a session on the very next cold
|
|
1413
|
+
// boot and sign the user back in without intent. Read the durable flag
|
|
1414
|
+
// ONCE here (synchronously usable by the step `enabled` gate) and skip
|
|
1415
|
+
// that step while it is set. Any deliberate sign-in clears it. The
|
|
1416
|
+
// `sso-bounce` step is already self-suppressed after sign-out (its
|
|
1417
|
+
// `hasPriorSession` hint is cleared), and the local `stored-session`
|
|
1418
|
+
// restore step is unaffected — a deliberate sign-out clears those
|
|
1419
|
+
// credentials anyway.
|
|
1358
1420
|
const silentRestoreBlocked = isSilentRestoreSuppressed();
|
|
1359
1421
|
|
|
1360
1422
|
try {
|
|
@@ -1374,28 +1436,22 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1374
1436
|
},
|
|
1375
1437
|
},
|
|
1376
1438
|
{
|
|
1377
|
-
// 2) Stored-session bearer restore.
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
// SIGNED-OUT GATE: after a deliberate full sign-out the stored session
|
|
1383
|
-
// state is cleared, so this would normally skip anyway — but the gate
|
|
1384
|
-
// makes that authoritative rather than incidental, so no residual
|
|
1385
|
-
// stored token can silently restore while the user is signed out.
|
|
1439
|
+
// 2) Stored-session bearer restore. NO `enabled` gate — runs on ALL
|
|
1440
|
+
// platforms. This is native's ONLY restore path (every web-only step
|
|
1441
|
+
// is disabled off-browser, so native reaches exactly this) AND the
|
|
1442
|
+
// common WEB reload winner.
|
|
1386
1443
|
//
|
|
1387
|
-
// ORDERING (FIX A): this step runs BEFORE the slow web-only
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
// (
|
|
1444
|
+
// ORDERING (FIX A): this step now runs BEFORE the slow web-only
|
|
1445
|
+
// `silent-iframe` probe. On a normal reload the local bearer
|
|
1446
|
+
// validates in one round-trip and wins; `runColdBoot`
|
|
1447
|
+
// then short-circuits and never even evaluates the slow
|
|
1448
|
+
// no-redirect probe that would otherwise time out (the ~20-30s
|
|
1449
|
+
// serial stall). The `sso-return` step stays AHEAD of this one —
|
|
1450
|
+
// it must consume the URL fragment before any later step (or
|
|
1451
|
+
// anything else) strips it. On a first visit with no local
|
|
1452
|
+
// session this step skips and the cross-domain fallback chain
|
|
1453
|
+
// (silent-iframe → sso-bounce) runs exactly as before.
|
|
1397
1454
|
id: 'stored-session',
|
|
1398
|
-
enabled: () => !silentRestoreBlocked,
|
|
1399
1455
|
run: async () => {
|
|
1400
1456
|
const restored = await restoreStoredSession();
|
|
1401
1457
|
if (restored) {
|
|
@@ -1458,62 +1514,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1458
1514
|
},
|
|
1459
1515
|
},
|
|
1460
1516
|
{
|
|
1461
|
-
//
|
|
1462
|
-
// ONLY when an explicit persisted slot selection exists
|
|
1463
|
-
// (`prioritizeMultiAccount`) — i.e. the user previously signed in or
|
|
1464
|
-
// SWITCHED accounts on this first-party app. It runs BEFORE the
|
|
1465
|
-
// `fedcm-silent` / `silent-iframe` probes (which only ever recover the
|
|
1466
|
-
// PRIMARY central session) so a switched managed/org account survives
|
|
1467
|
-
// reload instead of being clobbered back to the primary. The restore
|
|
1468
|
-
// itself is the SAME `restoreViaRefreshCookie` as the terminal-tier
|
|
1469
|
-
// step below: it rotates every device-local `oxy_rt_<authuser>` cookie
|
|
1470
|
-
// and picks the persisted slot via `selectActiveRefreshAccount`,
|
|
1471
|
-
// committing it (token + state + durable persistence + `markAuthResolved`).
|
|
1472
|
-
// When no slot is persisted this is disabled and the original order is
|
|
1473
|
-
// unchanged; when the slot's cookies have lapsed it returns no accounts
|
|
1474
|
-
// and the chain falls through to the cross-domain fallbacks below.
|
|
1475
|
-
//
|
|
1476
|
-
// SIGNED-OUT GATE: since PR #455 the primary's `oxy_rt` slot survives a
|
|
1477
|
-
// deliberate sign-out, so without this gate `refresh-all` would re-mint
|
|
1478
|
-
// and silently re-log-in the user on reload. The flag is cleared by any
|
|
1479
|
-
// deliberate sign-in (including an account SWITCH, which also writes the
|
|
1480
|
-
// new active slot), so the switch-survives-reload path is unaffected.
|
|
1481
|
-
id: 'cookie-restore-active',
|
|
1482
|
-
enabled: () => prioritizeMultiAccount && !silentRestoreBlocked,
|
|
1483
|
-
run: async () => {
|
|
1484
|
-
const restored = await restoreViaRefreshCookie();
|
|
1485
|
-
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
1486
|
-
},
|
|
1487
|
-
},
|
|
1488
|
-
{
|
|
1489
|
-
// 3) FedCM silent reauthn (Chrome) against the CENTRAL IdP
|
|
1490
|
-
// (auth.oxy.so). `silentSignInWithFedCM` plants the access token
|
|
1491
|
-
// internally; we commit the returned session via
|
|
1492
|
-
// `handleWebSSOSession`. Guarded so it fires at most once per page
|
|
1493
|
-
// load across remounts. This is an enhancement layered above the
|
|
1494
|
-
// opaque-code bounce: when it succeeds the bounce never fires.
|
|
1495
|
-
//
|
|
1496
|
-
// FIX-B: additionally skipped when the earlier `stored-session` step
|
|
1497
|
-
// already recovered a local session — the probe cannot improve on a
|
|
1498
|
-
// valid local bearer, and skipping it avoids the silent round-trip.
|
|
1499
|
-
id: 'fedcm-silent',
|
|
1500
|
-
enabled: () =>
|
|
1501
|
-
!storedSessionRestored &&
|
|
1502
|
-
!silentRestoreBlocked &&
|
|
1503
|
-
fedcmSupported &&
|
|
1504
|
-
!servicesSilentAttempted.has(silentKey),
|
|
1505
|
-
run: async () => {
|
|
1506
|
-
servicesSilentAttempted.add(silentKey);
|
|
1507
|
-
const session = await oxyServices.silentSignInWithFedCM?.();
|
|
1508
|
-
if (!session || !commitWebSession) {
|
|
1509
|
-
return { kind: 'skip' };
|
|
1510
|
-
}
|
|
1511
|
-
await commitWebSession(session);
|
|
1512
|
-
return { kind: 'session', session: true };
|
|
1513
|
-
},
|
|
1514
|
-
},
|
|
1515
|
-
{
|
|
1516
|
-
// 4) First-party silent iframe at the PER-APEX IdP — the DURABLE
|
|
1517
|
+
// 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
|
|
1517
1518
|
// cross-domain reload-restore path. The durable session lives as a
|
|
1518
1519
|
// first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
|
|
1519
1520
|
// `auth.mention.earth`), established during the `/sso` bounce's
|
|
@@ -1554,35 +1555,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1554
1555
|
},
|
|
1555
1556
|
},
|
|
1556
1557
|
{
|
|
1557
|
-
//
|
|
1558
|
-
// httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
|
|
1559
|
-
// device-local slot. On a cross-domain RP (mention.earth, …) the
|
|
1560
|
-
// cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
|
|
1561
|
-
// `refreshAllSessions` returns `{accounts:[]}` and this skips. That
|
|
1562
|
-
// is correct; cross-domain restore is handled by the SSO bounce.
|
|
1563
|
-
// FIX-D: `restoreViaRefreshCookie` bounds the request with
|
|
1564
|
-
// `COOKIE_RESTORE_TIMEOUT` so a cross-domain stall cannot hang here.
|
|
1565
|
-
//
|
|
1566
|
-
// Disabled when `prioritizeMultiAccount` already ran the
|
|
1567
|
-
// `cookie-restore-active` step above (an explicit persisted slot) — that
|
|
1568
|
-
// earlier step is the identical restore, so this terminal-tier copy
|
|
1569
|
-
// would be a redundant second `refreshAllSessions`. It still runs in the
|
|
1570
|
-
// common no-persisted-slot case (e.g. first visit to a first-party app
|
|
1571
|
-
// whose central refresh cookies already exist).
|
|
1572
|
-
//
|
|
1573
|
-
// SIGNED-OUT GATE (same rationale as `cookie-restore-active`): the
|
|
1574
|
-
// primary's `oxy_rt` slot survives a deliberate sign-out (PR #455), so
|
|
1575
|
-
// this must be skipped while the signed-out flag is set or it would
|
|
1576
|
-
// silently re-restore the primary on reload.
|
|
1577
|
-
id: 'cookie-restore',
|
|
1578
|
-
enabled: () => isWebBrowser() && !prioritizeMultiAccount && !silentRestoreBlocked,
|
|
1579
|
-
run: async () => {
|
|
1580
|
-
const restored = await restoreViaRefreshCookie();
|
|
1581
|
-
return restored ? { kind: 'session', session: true } : { kind: 'skip' };
|
|
1582
|
-
},
|
|
1583
|
-
},
|
|
1584
|
-
{
|
|
1585
|
-
// 6) SSO bounce (TERMINAL, web only, at most once). No local session
|
|
1558
|
+
// 4) SSO bounce (TERMINAL, web only, at most once). No local session
|
|
1586
1559
|
// was found by any step above. Top-level navigate to the central
|
|
1587
1560
|
// `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
|
|
1588
1561
|
// (returning an opaque code we exchange on the callback) or report
|
|
@@ -1660,9 +1633,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1660
1633
|
);
|
|
1661
1634
|
}
|
|
1662
1635
|
},
|
|
1663
|
-
// Defense-in-depth: a single step whose promise never settles
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1636
|
+
// Defense-in-depth: a single step whose promise never settles can no
|
|
1637
|
+
// longer block the chain forever. On expiry the runner keeps iterating
|
|
1638
|
+
// to the terminal `sso-bounce`
|
|
1666
1639
|
// step so a genuine no-local-session visit still reaches the
|
|
1667
1640
|
// cross-domain `/sso` fallback; the `finally` backstop flips
|
|
1668
1641
|
// `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
|
|
@@ -1683,6 +1656,81 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1683
1656
|
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1684
1657
|
);
|
|
1685
1658
|
}
|
|
1659
|
+
|
|
1660
|
+
// TOKEN LADDER → SESSIONCLIENT AUTHORITY HANDOFF. The steps above are
|
|
1661
|
+
// ONLY a token-acquisition ladder — they mint the first per-domain
|
|
1662
|
+
// access token by whichever means recovers one fastest. Once a session
|
|
1663
|
+
// is known (either this cold boot committed one via the ladder, or an
|
|
1664
|
+
// access token is already held in memory — e.g. a prior render already
|
|
1665
|
+
// planted it), hand off to the server-authoritative `SessionClient`:
|
|
1666
|
+
// `addCurrentAccount` registers this recovered account+session into the
|
|
1667
|
+
// server `DeviceSession` (derives identity from the bearer), `start`
|
|
1668
|
+
// bootstraps the full device-session state (server `activeAccountId` +
|
|
1669
|
+
// `activeToken`) and connects the realtime socket, and `syncFromClient`
|
|
1670
|
+
// projects that state onto the exposed `sessions`/`activeSessionId`/
|
|
1671
|
+
// `user`. This is what lets a switched (managed/org) account survive
|
|
1672
|
+
// reload: the SERVER, not a client-persisted slot, owns which account is
|
|
1673
|
+
// active. Never call the client when no session was acquired — an
|
|
1674
|
+
// anonymous visitor must stay logged out. Failures are logged and
|
|
1675
|
+
// swallowed; they must never throw out of cold boot.
|
|
1676
|
+
//
|
|
1677
|
+
// `addCurrentAccount` is SKIPPED when `registeredDuringBootRef` is
|
|
1678
|
+
// already `true` — every ladder step except `stored-session` commits
|
|
1679
|
+
// through `handleWebSSOSession`, which registers the account itself
|
|
1680
|
+
// (see its own `sessionClient.addCurrentAccount()` call). Without this
|
|
1681
|
+
// guard a winning `sso-return` / `shared-key-signin` / `silent-iframe`
|
|
1682
|
+
// step would register the SAME account twice
|
|
1683
|
+
// (`POST /session/device/add` called back-to-back). `start()` and
|
|
1684
|
+
// `syncFromClient()` still always run — `start()` is idempotent
|
|
1685
|
+
// (no-ops once already started) and is what connects the realtime
|
|
1686
|
+
// socket for the first time.
|
|
1687
|
+
if (outcome.kind === 'session' || oxyServices.getAccessToken()) {
|
|
1688
|
+
// Self-contained: never throws (own try/catch), so it is safe to let it
|
|
1689
|
+
// outlive the race below and finish in the background if the deadline
|
|
1690
|
+
// trips — the socket subscription then projects the state when it lands.
|
|
1691
|
+
const handoff = (async () => {
|
|
1692
|
+
try {
|
|
1693
|
+
if (!registeredDuringBootRef.current) {
|
|
1694
|
+
// Nested guard: registering this account into the device set is
|
|
1695
|
+
// best-effort. If it throws, `start()` must STILL run so the
|
|
1696
|
+
// realtime socket connects and state projects — cold boot
|
|
1697
|
+
// re-registers the account into the device set on the next load.
|
|
1698
|
+
try {
|
|
1699
|
+
await sessionClient.addCurrentAccount();
|
|
1700
|
+
} catch (addAccountErr) {
|
|
1701
|
+
loggerUtil.warn(
|
|
1702
|
+
'cold-boot: addCurrentAccount failed; continuing to start()',
|
|
1703
|
+
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1704
|
+
addAccountErr as unknown,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
await sessionClient.start();
|
|
1709
|
+
await syncFromClient();
|
|
1710
|
+
} catch (startErr) {
|
|
1711
|
+
loggerUtil.warn(
|
|
1712
|
+
'cold-boot: SessionClient start failed',
|
|
1713
|
+
{ component: 'OxyContext', method: 'restoreSessionsFromStorage' },
|
|
1714
|
+
startErr as unknown,
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
})();
|
|
1718
|
+
// Bound how long auth resolution waits for the handoff (see
|
|
1719
|
+
// `SESSION_HANDOFF_DEADLINE`): the token is already planted, so on a
|
|
1720
|
+
// slow backend we proceed to `markAuthResolved` and let the handoff
|
|
1721
|
+
// complete asynchronously rather than stalling the spinner.
|
|
1722
|
+
let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
|
|
1723
|
+
await Promise.race([
|
|
1724
|
+
handoff,
|
|
1725
|
+
new Promise<void>((resolve) => {
|
|
1726
|
+
handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE);
|
|
1727
|
+
}),
|
|
1728
|
+
]).finally(() => {
|
|
1729
|
+
if (handoffDeadlineId !== undefined) {
|
|
1730
|
+
clearTimeout(handoffDeadlineId);
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1686
1734
|
} catch (error) {
|
|
1687
1735
|
if (__DEV__) {
|
|
1688
1736
|
loggerUtil.error('Auth init error', error instanceof Error ? error : new Error(String(error)), { component: 'OxyContext', method: 'restoreSessionsFromStorage' });
|
|
@@ -1702,10 +1750,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1702
1750
|
oxyServices,
|
|
1703
1751
|
storage,
|
|
1704
1752
|
storageKeys.priorSession,
|
|
1705
|
-
restoreViaRefreshCookie,
|
|
1706
1753
|
restoreStoredSession,
|
|
1707
1754
|
runSsoReturn,
|
|
1708
1755
|
markAuthResolved,
|
|
1756
|
+
sessionClient,
|
|
1757
|
+
syncFromClient,
|
|
1709
1758
|
]);
|
|
1710
1759
|
|
|
1711
1760
|
useEffect(() => {
|
|
@@ -1826,34 +1875,38 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1826
1875
|
|
|
1827
1876
|
// A committed web session re-enables automatic silent restore: clear the
|
|
1828
1877
|
// durable "deliberately signed out" flag. This funnel is reached by deliberate
|
|
1829
|
-
// sign-ins (password,
|
|
1878
|
+
// sign-ins (password, `/sso` return) AND by the silent
|
|
1830
1879
|
// cold-boot steps — but those are GATED on the flag, so when it is set they
|
|
1831
1880
|
// never run and never reach here; clearing is therefore only hit on a genuine
|
|
1832
1881
|
// (re-)sign-in or when restore was already permitted, both correct.
|
|
1833
1882
|
clearSignedOut();
|
|
1834
1883
|
|
|
1835
|
-
// Register this
|
|
1836
|
-
//
|
|
1837
|
-
//
|
|
1838
|
-
//
|
|
1839
|
-
//
|
|
1840
|
-
//
|
|
1841
|
-
//
|
|
1842
|
-
//
|
|
1843
|
-
//
|
|
1844
|
-
// `
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
1847
|
-
// next
|
|
1848
|
-
//
|
|
1849
|
-
//
|
|
1850
|
-
//
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1884
|
+
// Register this recovered account+session into the server-authoritative
|
|
1885
|
+
// device-session set. Every web primary-session restore funnels through
|
|
1886
|
+
// here — per-apex `/auth/silent` iframe,
|
|
1887
|
+
// central `/sso` return, and keyless password sign-in — and each of them
|
|
1888
|
+
// needs the resulting account added to the device's `DeviceSession` doc so
|
|
1889
|
+
// the sign-in persists across reload / other tabs / devices and the
|
|
1890
|
+
// realtime `device:<deviceId>` socket has an account to track.
|
|
1891
|
+
// `sessionClient.addCurrentAccount()` (`POST /session/device/add`) derives
|
|
1892
|
+
// identity from the bearer this function just planted above;
|
|
1893
|
+
// `syncFromClient()` reprojects the resulting server state onto the
|
|
1894
|
+
// exposed sessions/activeSessionId/user. Best-effort: a failure here must
|
|
1895
|
+
// NEVER fail the sign-in itself — cold boot re-registers this account into
|
|
1896
|
+
// the device set on the next load regardless. `registeredDuringBootRef` is
|
|
1897
|
+
// flipped on success so the cold-boot post-ladder handoff (which reaches
|
|
1898
|
+
// every step, including this one) does not redundantly re-register the
|
|
1899
|
+
// SAME account with a second `POST /session/device/add`.
|
|
1900
|
+
try {
|
|
1901
|
+
await sessionClient.addCurrentAccount();
|
|
1902
|
+
registeredDuringBootRef.current = true;
|
|
1903
|
+
await syncFromClient();
|
|
1904
|
+
} catch (registrationError) {
|
|
1905
|
+
loggerUtil.warn(
|
|
1906
|
+
'handleWebSSOSession: failed to register session into device set',
|
|
1907
|
+
{ component: 'OxyContext', method: 'handleWebSSOSession' },
|
|
1908
|
+
registrationError as unknown,
|
|
1909
|
+
);
|
|
1857
1910
|
}
|
|
1858
1911
|
|
|
1859
1912
|
const clientSession = {
|
|
@@ -1863,7 +1916,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1863
1916
|
lastActive: new Date().toISOString(),
|
|
1864
1917
|
userId: session.user.id?.toString() ?? '',
|
|
1865
1918
|
isCurrent: true,
|
|
1866
|
-
...(typeof primaryAuthuser === 'number' ? { authuser: primaryAuthuser } : null),
|
|
1867
1919
|
};
|
|
1868
1920
|
|
|
1869
1921
|
updateSessions([clientSession], { merge: true });
|
|
@@ -1876,7 +1928,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1876
1928
|
// because `onAuthStateChange` triggers a route navigation that can
|
|
1877
1929
|
// interrupt/supersede a still-pending async write. Persisting first
|
|
1878
1930
|
// guarantees the durable record lands; that is exactly what was missing
|
|
1879
|
-
// when `oxy_session_session_ids` came back empty after a successful
|
|
1931
|
+
// when `oxy_session_session_ids` came back empty after a successful SSO
|
|
1880
1932
|
// sign-in (the user appeared logged in until reload, then had no session to
|
|
1881
1933
|
// restore). `persistSessionDurably` awaits the READY storage instance rather
|
|
1882
1934
|
// than reading the possibly-null `storage` state, so a sign-in fired the
|
|
@@ -1900,14 +1952,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1900
1952
|
fullUser = session.user as unknown as User;
|
|
1901
1953
|
}
|
|
1902
1954
|
loginSuccess(fullUser);
|
|
1903
|
-
// A session is now committed (
|
|
1955
|
+
// A session is now committed (per-apex iframe /
|
|
1904
1956
|
// SSO-return all funnel through here) — unblock the auth-resolution
|
|
1905
1957
|
// gate immediately, ahead of the cold-boot chain returning (idempotent).
|
|
1906
1958
|
markAuthResolvedRef.current();
|
|
1907
1959
|
onAuthStateChange?.(fullUser);
|
|
1908
|
-
}, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
|
|
1960
|
+
}, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably, sessionClient, syncFromClient]);
|
|
1909
1961
|
|
|
1910
|
-
// Expose `handleWebSSOSession` to the cold-boot
|
|
1962
|
+
// Expose `handleWebSSOSession` to the cold-boot iframe/SSO steps,
|
|
1911
1963
|
// which reference it through a ref because they are declared above this
|
|
1912
1964
|
// callback. Assigned synchronously on every render so the ref is populated
|
|
1913
1965
|
// before the cold-boot effect (gated on `storage`/`initialized`) can fire.
|
|
@@ -1915,7 +1967,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1915
1967
|
|
|
1916
1968
|
// Keyless native sign-in: username/email + password. The slimmed Accounts app
|
|
1917
1969
|
// (which no longer holds a local identity key) uses this; it commits a
|
|
1918
|
-
// successful session through the SAME `handleWebSSOSession` path
|
|
1970
|
+
// successful session through the SAME `handleWebSSOSession` path SSO
|
|
1919
1971
|
// use, so state, durable persistence, profile fetch, and `markAuthResolved`
|
|
1920
1972
|
// all run identically. The API returns either a full session OR a two-factor
|
|
1921
1973
|
// handoff (`{ twoFactorRequired, loginToken }`) — we surface the latter as a
|
|
@@ -1958,31 +2010,29 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1958
2010
|
[oxyServices, handleWebSSOSession],
|
|
1959
2011
|
);
|
|
1960
2012
|
|
|
1961
|
-
// Cross-domain silent SSO is
|
|
1962
|
-
//
|
|
1963
|
-
//
|
|
1964
|
-
//
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
1967
|
-
//
|
|
1968
|
-
// loosened; cold boot runs while `tokenReady` is false, so this never races
|
|
1969
|
-
// the cold-boot silent step.
|
|
1970
|
-
const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;
|
|
1971
|
-
|
|
1972
|
-
useWebSSO({
|
|
1973
|
-
oxyServices,
|
|
1974
|
-
onSessionFound: handleWebSSOSession,
|
|
1975
|
-
onError: (error) => {
|
|
1976
|
-
if (__DEV__) {
|
|
1977
|
-
loggerUtil.debug('Web SSO check failed (non-critical)', { component: 'OxyContext' }, error);
|
|
1978
|
-
}
|
|
1979
|
-
},
|
|
1980
|
-
enabled: shouldTryWebSSO,
|
|
1981
|
-
});
|
|
2013
|
+
// Cross-domain silent SSO is owned ENTIRELY by the `silent-iframe` cold-boot
|
|
2014
|
+
// step above (the ordered `runColdBoot` sequence) plus the terminal
|
|
2015
|
+
// `sso-bounce` fallback. There is no post-boot safety net here: the FedCM
|
|
2016
|
+
// silent post-boot retry that used to run via `useWebSSO` was removed along
|
|
2017
|
+
// with the rest of the client FedCM surface (Chrome-only; see
|
|
2018
|
+
// `CrossDomainAuth`'s doc comment in `@oxyhq/core` for the production
|
|
2019
|
+
// incident that motivated the removal).
|
|
1982
2020
|
|
|
1983
2021
|
// IdP session validation via lightweight iframe check
|
|
1984
2022
|
// When user returns to tab, verify auth.oxy.so still has their session
|
|
1985
2023
|
// If session is gone (cleared/logged out), clear local session too
|
|
2024
|
+
//
|
|
2025
|
+
// KEPT (session-sync cutover audit, Task 5): this is NOT redundant with the
|
|
2026
|
+
// `SessionClient` socket (`device:<deviceId>`, `session_state` push). That
|
|
2027
|
+
// socket only fires for revocations that flow through oxy-api's
|
|
2028
|
+
// `DeviceSession` authority (`POST /session/device/signout` and friends). A
|
|
2029
|
+
// sign-out at the CENTRAL IdP itself (`auth.oxy.so`'s own `AuthSession` /
|
|
2030
|
+
// `fedcm_session` — e.g. a direct IdP-side sign-out, or an admin/security
|
|
2031
|
+
// revocation there) does not currently touch `DeviceSession` at all, so no
|
|
2032
|
+
// socket push follows — this iframe poll is the ONLY mechanism that catches
|
|
2033
|
+
// it today. A future phase should move this server-side (IdP deactivation
|
|
2034
|
+
// -> `DeviceSession` update -> broadcast over the same socket), at which
|
|
2035
|
+
// point this client-side poll becomes redundant and can be deleted.
|
|
1986
2036
|
const lastIdPCheckRef = useRef<number>(0);
|
|
1987
2037
|
const pendingIdPCleanupRef = useRef<(() => void) | null>(null);
|
|
1988
2038
|
|
|
@@ -2058,51 +2108,45 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2058
2108
|
};
|
|
2059
2109
|
}, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
|
|
2060
2110
|
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
toast.info('You have been signed out remotely.');
|
|
2082
|
-
logout().catch((remoteError) => logger('Failed to process remote sign out', remoteError));
|
|
2083
|
-
}, [logger, logout]);
|
|
2084
|
-
|
|
2085
|
-
useSessionSocket({
|
|
2086
|
-
userId,
|
|
2087
|
-
activeSessionId,
|
|
2088
|
-
currentDeviceId,
|
|
2089
|
-
refreshSessions: refreshSessionsWithUser,
|
|
2090
|
-
clearSessionState,
|
|
2091
|
-
baseURL: oxyServices.getBaseURL(),
|
|
2092
|
-
getAccessToken: () => oxyServices.getAccessToken(),
|
|
2093
|
-
onRemoteSignOut: handleRemoteSignOut,
|
|
2094
|
-
onSessionRemoved: handleSessionRemoved,
|
|
2095
|
-
});
|
|
2096
|
-
|
|
2111
|
+
// Exposed `refreshSessions`: re-bootstraps the server-authoritative device
|
|
2112
|
+
// state (`GET /session/device/state`, the SAME request `SessionClient.start()`
|
|
2113
|
+
// makes) and reprojects it via `syncFromClient` — a manual pull-to-refresh
|
|
2114
|
+
// counterpart to the realtime `device:<deviceId>` socket `client.start()`
|
|
2115
|
+
// already owns (there is no per-domain `useSessionSocket` anymore; a REMOTE
|
|
2116
|
+
// sign-out or account change arrives over that socket and is handled by
|
|
2117
|
+
// `syncFromClient`'s subscription below, including the full-sign-out gap fix
|
|
2118
|
+
// documented above it).
|
|
2119
|
+
const refreshSessionsForContext = useCallback(async (): Promise<void> => {
|
|
2120
|
+
await sessionClient.bootstrap();
|
|
2121
|
+
await syncFromClient();
|
|
2122
|
+
}, [sessionClient, syncFromClient]);
|
|
2123
|
+
|
|
2124
|
+
// Exposed `switchSession`: routes through the server-authoritative
|
|
2125
|
+
// `SessionClient` (Fase 3-B) rather than `useSessionManagement`'s own
|
|
2126
|
+
// `switchSession` (which remains, unchanged, for `useAuthOperations`'
|
|
2127
|
+
// internal same-user duplicate-session dedup — a different, legacy
|
|
2128
|
+
// session-validate concept unrelated to switching the device's ACTIVE
|
|
2129
|
+
// account). Resolves the target account from the current device state,
|
|
2130
|
+
// asks the server to switch, reprojects, and returns the now-active user.
|
|
2097
2131
|
const switchSessionForContext = useCallback(
|
|
2098
2132
|
async (sessionId: string): Promise<User> => {
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2133
|
+
const targetAccountId = sessionClient
|
|
2134
|
+
.getState()
|
|
2135
|
+
?.accounts.find((account) => account.sessionId === sessionId)?.accountId;
|
|
2136
|
+
if (!targetAccountId) {
|
|
2137
|
+
throw new Error(`No device account found for session "${sessionId}"`);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
await sessionClient.switchAccount(targetAccountId);
|
|
2141
|
+
await syncFromClient();
|
|
2142
|
+
|
|
2143
|
+
const activeUser = useAuthStore.getState().user;
|
|
2144
|
+
if (!activeUser) {
|
|
2145
|
+
throw new Error('Active account profile could not be resolved after switch');
|
|
2146
|
+
}
|
|
2147
|
+
return activeUser;
|
|
2104
2148
|
},
|
|
2105
|
-
[
|
|
2149
|
+
[sessionClient, syncFromClient],
|
|
2106
2150
|
);
|
|
2107
2151
|
|
|
2108
2152
|
// Identity management wrappers (delegate to KeyManager)
|
|
@@ -2162,16 +2206,42 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2162
2206
|
}
|
|
2163
2207
|
}, [isAuthenticated, initialized, tokenReady, refreshAccounts]);
|
|
2164
2208
|
|
|
2209
|
+
// Shared post-switch side effects, run identically regardless of which
|
|
2210
|
+
// `switchToAccount` branch handled the switch below: reload the switchable
|
|
2211
|
+
// account graph (the new active account's relationships differ) and
|
|
2212
|
+
// invalidate every query so all data refetches as the new account.
|
|
2213
|
+
const runPostAccountSwitchSideEffects = useCallback(async (): Promise<void> => {
|
|
2214
|
+
await refreshAccounts();
|
|
2215
|
+
queryClient.invalidateQueries();
|
|
2216
|
+
}, [refreshAccounts, queryClient]);
|
|
2217
|
+
|
|
2165
2218
|
// Switch the active session INTO an account from the unified graph. In the
|
|
2166
|
-
// REAL-SESSION model this is identical to switching device sign-ins:
|
|
2167
|
-
//
|
|
2168
|
-
//
|
|
2169
|
-
//
|
|
2170
|
-
//
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
//
|
|
2219
|
+
// REAL-SESSION model this is identical to switching device sign-ins: the
|
|
2220
|
+
// whole app becomes that account. There is exactly ONE uniform path for
|
|
2221
|
+
// that — the device's server-authoritative `SessionClient.switchAccount()`
|
|
2222
|
+
// — used for EVERY switch, org/managed accounts included:
|
|
2223
|
+
//
|
|
2224
|
+
// - If `accountId` is ALREADY registered on this device's multi-account
|
|
2225
|
+
// set (a prior `switchToAccount`/sign-in already added it), switch
|
|
2226
|
+
// straight through `sessionClient.switchAccount()` — the SAME path
|
|
2227
|
+
// `switchSession` uses for ordinary device sessions. No new session is
|
|
2228
|
+
// minted, so no session churn / deactivation of the account's existing
|
|
2229
|
+
// session (H4).
|
|
2230
|
+
// - Only the FIRST time an account is switched into does it need minting:
|
|
2231
|
+
// `oxyServices.switchToAccount` mints+plants a real session, then
|
|
2232
|
+
// `sessionClient.addCurrentAccount()` registers it into the device set
|
|
2233
|
+
// (server-set httpOnly `oxy_rt_<authuser>` cookie, so the session joins
|
|
2234
|
+
// the device multi-account set and survives reload / `refresh-all`).
|
|
2235
|
+
// Every subsequent switch into that same account takes the branch above.
|
|
2174
2236
|
const switchToAccount = useCallback(async (accountId: string): Promise<void> => {
|
|
2237
|
+
const deviceState = sessionClient.getState();
|
|
2238
|
+
if (deviceState?.accounts.some((account) => account.accountId === accountId)) {
|
|
2239
|
+
await sessionClient.switchAccount(accountId);
|
|
2240
|
+
await syncFromClient();
|
|
2241
|
+
await runPostAccountSwitchSideEffects();
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2175
2245
|
const result = await oxyServices.switchToAccount(accountId);
|
|
2176
2246
|
if (!result?.user || !result?.sessionId) {
|
|
2177
2247
|
throw new Error('Account switch did not return a valid session');
|
|
@@ -2183,8 +2253,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2183
2253
|
|
|
2184
2254
|
// `oxyServices.switchToAccount` already planted `result.accessToken` as the
|
|
2185
2255
|
// active token; mirror the minted session into the multi-account store and
|
|
2186
|
-
// mark it current
|
|
2187
|
-
//
|
|
2256
|
+
// mark it current. The device account SET (and its `authuser` slot
|
|
2257
|
+
// numbering) is server-authoritative via `SessionClient` — the
|
|
2258
|
+
// `addCurrentAccount` + `syncFromClient` reprojection below supersedes this
|
|
2259
|
+
// local mirror with the server's own state.
|
|
2188
2260
|
const now = new Date();
|
|
2189
2261
|
const clientSession: ClientSession = {
|
|
2190
2262
|
sessionId: result.sessionId,
|
|
@@ -2193,15 +2265,33 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2193
2265
|
lastActive: now.toISOString(),
|
|
2194
2266
|
userId: result.user.id?.toString() ?? '',
|
|
2195
2267
|
isCurrent: true,
|
|
2196
|
-
...(typeof result.authuser === 'number' ? { authuser: result.authuser } : null),
|
|
2197
2268
|
};
|
|
2198
2269
|
updateSessions([clientSession], { merge: true });
|
|
2199
2270
|
setActiveSessionId(result.sessionId);
|
|
2200
|
-
if (isWebBrowser() && typeof result.authuser === 'number') {
|
|
2201
|
-
writeActiveAuthuser(result.authuser);
|
|
2202
|
-
}
|
|
2203
2271
|
await persistSessionDurably(result.sessionId);
|
|
2204
2272
|
|
|
2273
|
+
// Register the switched-to account into the device's server-authoritative
|
|
2274
|
+
// multi-account set (the SAME `SessionClient.addCurrentAccount()` the
|
|
2275
|
+
// cold-boot handoff uses — it derives identity from the bearer, which
|
|
2276
|
+
// `oxyServices.switchToAccount` already planted above) and reproject
|
|
2277
|
+
// state, so the switch persists across reload / other tabs / devices
|
|
2278
|
+
// instead of only living in this provider's local session-management
|
|
2279
|
+
// state. Best-effort: the token for the new account is already planted, so
|
|
2280
|
+
// a transient failure here must NEVER reject the switch — fall through to
|
|
2281
|
+
// `loginSuccess`/`onAuthStateChange` so the UI reflects the switched-to
|
|
2282
|
+
// account (cold boot re-registers into the device set on next load).
|
|
2283
|
+
// Mirrors the same guard in `handleWebSSOSession`.
|
|
2284
|
+
try {
|
|
2285
|
+
await sessionClient.addCurrentAccount();
|
|
2286
|
+
await syncFromClient();
|
|
2287
|
+
} catch (registrationError) {
|
|
2288
|
+
loggerUtil.warn(
|
|
2289
|
+
'switchToAccount: failed to register account into device set',
|
|
2290
|
+
{ component: 'OxyContext', method: 'switchToAccount' },
|
|
2291
|
+
registrationError as unknown,
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2205
2295
|
// Fetch the canonical User for the new account (the switch result carries
|
|
2206
2296
|
// only MinimalUserData); fall back to that minimal shape if the profile
|
|
2207
2297
|
// fetch fails so the app still reflects the switched identity.
|
|
@@ -2217,13 +2307,8 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2217
2307
|
loginSuccess(fullUser);
|
|
2218
2308
|
onAuthStateChange?.(fullUser);
|
|
2219
2309
|
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
// account — the deviceAccounts probe re-enumerates the multi-account set so
|
|
2223
|
-
// the switched account now appears as a device session.
|
|
2224
|
-
await refreshAccounts();
|
|
2225
|
-
queryClient.invalidateQueries();
|
|
2226
|
-
}, [oxyServices, updateSessions, setActiveSessionId, persistSessionDurably, loginSuccess, onAuthStateChange, refreshAccounts, queryClient]);
|
|
2310
|
+
await runPostAccountSwitchSideEffects();
|
|
2311
|
+
}, [oxyServices, updateSessions, setActiveSessionId, persistSessionDurably, sessionClient, syncFromClient, loginSuccess, onAuthStateChange, runPostAccountSwitchSideEffects]);
|
|
2227
2312
|
|
|
2228
2313
|
const createAccountFn = useCallback(async (data: CreateAccountInput): Promise<AccountNode> => {
|
|
2229
2314
|
const account = await oxyServices.createAccount(data);
|
|
@@ -2260,7 +2345,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2260
2345
|
logoutAll,
|
|
2261
2346
|
switchSession: switchSessionForContext,
|
|
2262
2347
|
removeSession: logout,
|
|
2263
|
-
refreshSessions:
|
|
2348
|
+
refreshSessions: refreshSessionsForContext,
|
|
2264
2349
|
setLanguage,
|
|
2265
2350
|
getDeviceSessions,
|
|
2266
2351
|
logoutAllDeviceSessions,
|
|
@@ -2301,7 +2386,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
2301
2386
|
oxyServices,
|
|
2302
2387
|
storageKeyPrefix,
|
|
2303
2388
|
clientId,
|
|
2304
|
-
|
|
2389
|
+
refreshSessionsForContext,
|
|
2305
2390
|
sessions,
|
|
2306
2391
|
setLanguage,
|
|
2307
2392
|
storage,
|