Summary
On the same device, logging in as user B after being logged in as user A leaves the app showing user A's persisted state (account webviews, threads, chat history, notifications, skills, composio cache) even though active_user.toml and the user-scoped auth-profiles.json have correctly flipped to user B. User B sees user A's private data.
Severity: confidentiality. Two users on one device leak each other's app state to each other.
Problem
What happens
- Launch app, authenticate as user A. App populates Redux slices (
accounts, thread, chatRuntime, notifications, providerSurfaces, skills, composio, …) and redux-persist writes them to localStorage under the persist:* keys.
- Log out (or let the session expire) and authenticate as user B via the same OAuth button.
- Core correctly:
- Calls
GET /auth/me with user B's token.
- Writes
~/.openhuman/active_user.toml → user_id = "<B>".
- Creates / activates
~/.openhuman/users/<B>/, writes auth-profiles.json with user B's token and metadata.user_id = <B>.
- Subsequent
app_state_snapshot RPCs return user B's auth + config.
- Frontend instead shows user A's account rail, chat threads, chat-runtime tool timelines, notifications, composio-connected toolkits, etc. Fresh user B (brand-new account) should see an empty state and instead sees user A's world.
What should happen
On identity flip (snapshotIdentity(previous) !== snapshotIdentity(next) in CoreStateProvider), every user-scoped slice — not just teams/teamMembersById/teamInvitesById — must be reset. Caches inside singleton services (socketService, coreRpcClient, any *Api clients with a token header baked at construction) must be rebuilt against the new token.
Reproducible on feat/883-welcome-chat-lockdown (branch HEAD 08b709a5 at time of filing) on macOS dev build (pnpm dev:app). Very likely reproduces on main post-#886 as well — none of the code paths below are branch-specific.
Steps to reproduce (minimal)
- Fresh
~/.openhuman/ or at least a clean per-device localStorage for the Tauri origin.
pnpm dev:app. Log in with Google account A. Let the welcome flow / chat land. Connect one webview account (e.g. add a WhatsApp tile) so the account rail has at least one non-Agent entry. Send at least one chat message so the thread list is non-empty.
- Sign out or trigger logout. Log back in with Google account B (must be a different account — easiest way is a fresh cloud user).
- Observe: the left account rail still shows account A's WhatsApp tile, the thread sidebar still shows A's thread titles, the chat pane may still show A's last messages, notifications still list A's events.
Evidence
Captured via a per-second file watcher during a live repro (~/.openhuman/active_user.toml + each users/<id>/auth-profiles.json) plus a filtered core log:
00:29:40 User-scoped directory activated user_id=69ebba01f2fb928763e609a1
auth_store_session -> ok (1948ms)
00:30:02 User-scoped directory activated user_id=69ebbdadf2fb928763e61311
auth_store_session -> ok (543ms)
active_user.toml flipped atomically to 69ebbdadf2fb928763e61311 (user B).
- Token body in
users/69ebbdadf2fb928763e61311/auth-profiles.json is 1194 bytes with metadata.user_id = 69ebbdadf2fb928763e61311.
- Old user dirs (
69dce290…, 69de4c7c…, 69ebba01…) have 120-byte stub auth-profiles.json with empty metadata.user_id — token correctly moved.
- Core routes chat to the
welcome agent with chat_onboarding_completed=false for user B — as expected for a fresh user.
So the Rust core is correct. The leak is entirely in the React layer.
Suspected cause
In app/src/providers/CoreStateProvider.tsx the identity-change branch only clears the three team caches:
// CoreStateProvider.tsx:146-148
const shouldClearScopedCaches =
previousIdentity !== nextIdentity ||
(previous.snapshot.auth.isAuthenticated && !nextSnapshot.auth.isAuthenticated);
return {
...previous,
isBootstrapping: false,
isReady: true,
snapshot: nextSnapshot,
teams: shouldClearScopedCaches ? [] : previous.teams,
teamMembersById: shouldClearScopedCaches ? {} : previous.teamMembersById,
teamInvitesById: shouldClearScopedCaches ? {} : previous.teamInvitesById,
};
Every other user-scoped Redux slice is untouched, and redux-persist's persist:root blob is per-device, so on the next hydration cycle user A's slices are restored verbatim into the user B session.
Known user-scoped slices that need to reset on identity flip (non-exhaustive; grep for more):
accountsSlice — accounts, order, activeAccountId, unread
threadSlice — threads, selectedThreadId, messagesByThreadId, suggestedQuestions, activeThreadId
chatRuntimeSlice — toolTimelineByThread, inferenceStatusByThread, streamingAssistantByThread, inferenceTurnLifecycleByThread
notificationSlice — items + read state
providerSurfacesSlice — respond queue
socketSlice / socketSelectors — per-user client id, status
skillsSlice / skills caches — installed & discovered lists
composioSlice — connected toolkits, hooks caches in useComposioConnections
- Anything in
authSlice / userSlice that isn't already derived from snapshot.currentUser
Singleton services that may hold user-scoped state:
socketService.getSocket() — connected socket instance; the client_id is keyed to the previous user's session. Needs disconnect() + reconnect after the new token lands.
coreRpcClient — if it caches headers / auth at first call
apiClient in services/api/*.ts — check for module-level Authorization header wiring
core-state:session-token-updated dispatch — fires on deep-link token update and already calls refresh(), but same-session login-without-logout may never fire it.
Solution (proposed)
- In
CoreStateProvider.refreshCore, on previousIdentity !== nextIdentity, dispatch a new top-level action resetUserScopedState that every user-scoped slice handles via extraReducers, returning each slice's initial state. (Or a single slice-owner-registered list in store/index.ts.) This is pure-Redux, no redux-persist knowledge required.
- Add a
persistor.purge() (or targeted persistReducer whitelist changes keyed by userId) so the persisted blob does not re-hydrate stale state on the next app launch for the new user. Easier alternative: key the persist root with the userId suffix so each user gets their own storage key and cross-user hydration is impossible by construction.
- In
socketService, add a resetForUser(userId: string) that disconnects, drops the cached client_id, and reconnects — called from the same identity-flip branch.
- Audit
services/api/*.ts and lib/mcp/* for module-level auth state; ensure everything reads the token from CoreStateProvider.snapshot.sessionToken live.
- Regression test: a Vitest that mounts
CoreStateProvider, seeds Redux with user-A state, flips the mocked fetchCoreAppSnapshot to user B, and asserts the affected slices are back to their initial shape.
The storeSessionToken helper in CoreStateProvider (line ~373) already has the full hook surface — it calls refresh() + refreshTeams(). Adding the global reset dispatch + socketService.resetForUser() there closes the gap for the in-app login path; the core-state:session-token-updated deep-link path reuses the same refresh.
Acceptance criteria
Related
Summary
On the same device, logging in as user B after being logged in as user A leaves the app showing user A's persisted state (account webviews, threads, chat history, notifications, skills, composio cache) even though
active_user.tomland the user-scopedauth-profiles.jsonhave correctly flipped to user B. User B sees user A's private data.Severity: confidentiality. Two users on one device leak each other's app state to each other.
Problem
What happens
accounts,thread,chatRuntime,notifications,providerSurfaces,skills,composio, …) andredux-persistwrites them tolocalStorageunder thepersist:*keys.GET /auth/mewith user B's token.~/.openhuman/active_user.toml→user_id = "<B>".~/.openhuman/users/<B>/, writesauth-profiles.jsonwith user B's token andmetadata.user_id = <B>.app_state_snapshotRPCs return user B's auth + config.What should happen
On identity flip (
snapshotIdentity(previous) !== snapshotIdentity(next)inCoreStateProvider), every user-scoped slice — not justteams/teamMembersById/teamInvitesById— must be reset. Caches inside singleton services (socketService,coreRpcClient, any*Apiclients with a token header baked at construction) must be rebuilt against the new token.Reproducible on
feat/883-welcome-chat-lockdown(branch HEAD08b709a5at time of filing) on macOS dev build (pnpm dev:app). Very likely reproduces onmainpost-#886 as well — none of the code paths below are branch-specific.Steps to reproduce (minimal)
~/.openhuman/or at least a clean per-devicelocalStoragefor the Tauri origin.pnpm dev:app. Log in with Google account A. Let the welcome flow / chat land. Connect one webview account (e.g. add a WhatsApp tile) so the account rail has at least one non-Agent entry. Send at least one chat message so the thread list is non-empty.Evidence
Captured via a per-second file watcher during a live repro (
~/.openhuman/active_user.toml+ eachusers/<id>/auth-profiles.json) plus a filtered core log:active_user.tomlflipped atomically to69ebbdadf2fb928763e61311(user B).users/69ebbdadf2fb928763e61311/auth-profiles.jsonis 1194 bytes withmetadata.user_id = 69ebbdadf2fb928763e61311.69dce290…,69de4c7c…,69ebba01…) have 120-byte stubauth-profiles.jsonwith emptymetadata.user_id— token correctly moved.welcomeagent withchat_onboarding_completed=falsefor user B — as expected for a fresh user.So the Rust core is correct. The leak is entirely in the React layer.
Suspected cause
In
app/src/providers/CoreStateProvider.tsxthe identity-change branch only clears the three team caches:Every other user-scoped Redux slice is untouched, and
redux-persist'spersist:rootblob is per-device, so on the next hydration cycle user A's slices are restored verbatim into the user B session.Known user-scoped slices that need to reset on identity flip (non-exhaustive; grep for more):
accountsSlice—accounts,order,activeAccountId,unreadthreadSlice—threads,selectedThreadId,messagesByThreadId,suggestedQuestions,activeThreadIdchatRuntimeSlice—toolTimelineByThread,inferenceStatusByThread,streamingAssistantByThread,inferenceTurnLifecycleByThreadnotificationSlice—items+ read stateproviderSurfacesSlice— respond queuesocketSlice/socketSelectors— per-user client id, statusskillsSlice/ skills caches — installed & discovered listscomposioSlice— connected toolkits,hookscaches inuseComposioConnectionsauthSlice/userSlicethat isn't already derived fromsnapshot.currentUserSingleton services that may hold user-scoped state:
socketService.getSocket()— connected socket instance; theclient_idis keyed to the previous user's session. Needsdisconnect()+ reconnect after the new token lands.coreRpcClient— if it caches headers / auth at first callapiClientinservices/api/*.ts— check for module-levelAuthorizationheader wiringcore-state:session-token-updateddispatch — fires on deep-link token update and already callsrefresh(), but same-session login-without-logout may never fire it.Solution (proposed)
CoreStateProvider.refreshCore, onpreviousIdentity !== nextIdentity, dispatch a new top-level actionresetUserScopedStatethat every user-scoped slice handles viaextraReducers, returning each slice's initial state. (Or a single slice-owner-registered list instore/index.ts.) This is pure-Redux, noredux-persistknowledge required.persistor.purge()(or targetedpersistReducerwhitelist changes keyed byuserId) so the persisted blob does not re-hydrate stale state on the next app launch for the new user. Easier alternative: key the persist root with theuserIdsuffix so each user gets their own storage key and cross-user hydration is impossible by construction.socketService, add aresetForUser(userId: string)that disconnects, drops the cached client_id, and reconnects — called from the same identity-flip branch.services/api/*.tsandlib/mcp/*for module-level auth state; ensure everything reads the token fromCoreStateProvider.snapshot.sessionTokenlive.CoreStateProvider, seeds Redux with user-A state, flips the mockedfetchCoreAppSnapshotto user B, and asserts the affected slices are back to their initial shape.The
storeSessionTokenhelper inCoreStateProvider(line ~373) already has the full hook surface — it callsrefresh()+refreshTeams(). Adding the global reset dispatch +socketService.resetForUser()there closes the gap for the in-app login path; thecore-state:session-token-updateddeep-link path reuses the same refresh.Acceptance criteria
localStorageinspection after user B login shows no user A slice data. Either the persist root is scoped peruserId, orpersistor.purge()has run.socketServicedisconnects + reconnects on identity flip; backend sees a newclient_idfor user B.CoreStateProvideridentity flip that asserts reset of every user-scoped slice.Related
src/openhuman/credentials/ops.rs::store_session(correctly scopes tokens tousers/<id>/auth-profiles.json).app/src/providers/CoreStateProvider.tsx(identity-change branch),app/src/services/socketService.ts,app/src/store/index.ts(persist config).