Skip to content

[Bug] Auth state leaks across users on same device — user B sees user A's threads, accounts, notifications #900

@oxoxDev

Description

@oxoxDev

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

  1. 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.
  2. Log out (or let the session expire) and authenticate as user B via the same OAuth button.
  3. Core correctly:
    • Calls GET /auth/me with user B's token.
    • Writes ~/.openhuman/active_user.tomluser_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.
  4. 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)

  1. Fresh ~/.openhuman/ or at least a clean per-device localStorage for the Tauri origin.
  2. 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.
  3. Sign out or trigger logout. Log back in with Google account B (must be a different account — easiest way is a fresh cloud user).
  4. 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):

  • accountsSliceaccounts, order, activeAccountId, unread
  • threadSlicethreads, selectedThreadId, messagesByThreadId, suggestedQuestions, activeThreadId
  • chatRuntimeSlicetoolTimelineByThread, inferenceStatusByThread, streamingAssistantByThread, inferenceTurnLifecycleByThread
  • notificationSliceitems + 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)

  1. 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.
  2. 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.
  3. In socketService, add a resetForUser(userId: string) that disconnects, drops the cached client_id, and reconnects — called from the same identity-flip branch.
  4. Audit services/api/*.ts and lib/mcp/* for module-level auth state; ensure everything reads the token from CoreStateProvider.snapshot.sessionToken live.
  5. 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

  • Repro gone — After logging out of user A and into user B on the same device, the account rail, thread list, chat runtime timeline, notifications, composio-connected toolkits, and skills list all show user B's (empty-for-a-new-user) state — no user A data leaks through.
  • Persist hygienelocalStorage inspection after user B login shows no user A slice data. Either the persist root is scoped per userId, or persistor.purge() has run.
  • Socket rebindsocketService disconnects + reconnects on identity flip; backend sees a new client_id for user B.
  • Regression test — Vitest around CoreStateProvider identity flip that asserts reset of every user-scoped slice.
  • No latency regression — Identity flip still lands within one snapshot poll (≤2s) and doesn't introduce a re-bootstrap storm on first login.

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions