Skip to content

Commit aecf9f9

Browse files
fix(auth): isolate user data across logout and cold-start transitions
Eliminates cross-user data leaks in the mobile/web/extension app by plugging gaps in the logout cleanup and persisted-cache rehydration flow. - AuthContext.logout() now clears the in-memory QueryClient (A1) so the next login does not flash the previous user's userProfile, eventTypes, bookings, or schedules under staleTime: Infinity + refetchOnMount: false. - AuthContext.logout() now calls clearWidgetBookings() (A3) so the iOS / Android home-screen widget does not keep showing the signed-out user's meetings. - AuthContext.logout()'s clearAuth() step is wrapped in its own try/catch (A5) so a SecureStore failure no longer skips region/cache/widget cleanup and leaves a zombie session with isAuthenticated still true. - useWidgetSync no longer fires syncBookingsToWidget on mount when isAuthenticated is false (A6), preventing an unauthenticated /bookings fallback during cold start before tokens are loaded. - queryPersister.restoreClient checks for cal_oauth_tokens before rehydrating (A2 pt 1); if no tokens exist the orphaned cache is wiped so a previously-logged-in user's data is never restored into a logged- out cold start. - setupAfterLogin compares the rehydrated userProfile.id to the just- fetched profile and wipes both in-memory and persisted caches on a mismatch (A2 pt 2).
1 parent a6739dd commit aecf9f9

3 files changed

Lines changed: 133 additions & 46 deletions

File tree

apps/mobile/contexts/AuthContext.tsx

Lines changed: 99 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useQueryClient } from "@tanstack/react-query";
12
import {
23
createContext,
34
type ReactNode,
@@ -7,6 +8,7 @@ import {
78
useRef,
89
useState,
910
} from "react";
11+
import { queryKeys } from "@/config/cache.config";
1012
import { USER_PREFERENCES_KEY } from "@/hooks/useUserPreferences";
1113
import { CalComAPIService } from "@/services/calcom";
1214
import {
@@ -19,6 +21,7 @@ import { WebAuthService } from "@/services/webAuth";
1921
import { clearQueryCache } from "@/utils/queryPersister";
2022
import { clearRegion, getRegion, preloadRegion, subscribeRegion } from "@/utils/region";
2123
import { generalStorage, secureStorage } from "@/utils/storage";
24+
import { clearWidgetBookings } from "@/utils/widgetStorage";
2225

2326
/**
2427
* Simplified user info stored in auth context
@@ -71,6 +74,10 @@ export function AuthProvider({ children }: AuthProviderProps) {
7174
const [userInfo, setUserInfo] = useState<AuthUserInfo | null>(null);
7275
const [isWebSession, setIsWebSession] = useState(false);
7376
const [loading, setLoading] = useState(true);
77+
// AuthProvider is mounted inside QueryProvider (see app/_layout.tsx), so
78+
// useQueryClient() resolves the live client we need to wipe on logout and
79+
// on cross-user cache rehydration.
80+
const queryClient = useQueryClient();
7481
// Construct synchronously on first render so downstream hooks can rely on a
7582
// non-null service immediately and mount-time configuration failures are
7683
// logged right away. The effect below rebuilds only if `preloadRegion()`
@@ -98,25 +105,52 @@ export function AuthProvider({ children }: AuthProviderProps) {
98105
}, []);
99106

100107
// Common post-login setup: configure API service and fetch user profile
101-
const setupAfterLogin = useCallback(async (token: string, refreshToken?: string) => {
102-
CalComAPIService.setAccessToken(token, refreshToken);
108+
const setupAfterLogin = useCallback(
109+
async (token: string, refreshToken?: string) => {
110+
CalComAPIService.setAccessToken(token, refreshToken);
103111

104-
try {
105-
const profile = await CalComAPIService.getUserProfile();
106-
// Store user info for use in the app (e.g., to display "You" in bookings)
107-
if (profile) {
108-
setUserInfo({
109-
email: profile.email,
110-
name: profile.name,
111-
id: profile.id,
112-
username: profile.username,
113-
});
112+
try {
113+
const profile = await CalComAPIService.getUserProfile();
114+
// Store user info for use in the app (e.g., to display "You" in bookings)
115+
if (profile) {
116+
// If the persisted cache was rehydrated for a different user (cold
117+
// start before logout fully ran, or a foreign cache that survived
118+
// restore), wipe everything before the next fetch lands so no PII
119+
// from the previous identity flashes onto screen.
120+
const cachedProfile = queryClient.getQueryData<UserProfile>(
121+
queryKeys.userProfile.current()
122+
);
123+
if (cachedProfile && cachedProfile.id !== profile.id) {
124+
if (__DEV__) {
125+
console.debug(
126+
"[AuthContext] Rehydrated cache belonged to a different user; clearing"
127+
);
128+
}
129+
try {
130+
queryClient.clear();
131+
} catch (clearError) {
132+
console.warn("Failed to clear stale in-memory cache:", clearError);
133+
}
134+
try {
135+
await clearQueryCache();
136+
} catch (cacheError) {
137+
console.warn("Failed to clear stale persisted cache:", cacheError);
138+
}
139+
}
140+
setUserInfo({
141+
email: profile.email,
142+
name: profile.name,
143+
id: profile.id,
144+
username: profile.username,
145+
});
146+
}
147+
} catch (profileError) {
148+
console.error("Failed to fetch user profile:", profileError);
149+
// Don't fail login if profile fetch fails
114150
}
115-
} catch (profileError) {
116-
console.error("Failed to fetch user profile:", profileError);
117-
// Don't fail login if profile fetch fails
118-
}
119-
}, []);
151+
},
152+
[queryClient]
153+
);
120154

121155
// Plain async fn (no useCallback): identity doesn't need to be stable for
122156
// memoization, and taking `service` explicitly lets cold-start callers pass
@@ -161,38 +195,59 @@ export function AuthProvider({ children }: AuthProviderProps) {
161195
}, []);
162196

163197
const logout = useCallback(async () => {
198+
// Each cleanup step has its own try/catch so a failure in one does not
199+
// skip the others. resetAuthState() always runs last to put the UI in a
200+
// known logged-out state even if disk writes failed.
164201
try {
165202
await clearAuth();
166-
// Clear user preferences to ensure fresh state for next user
167-
try {
168-
await generalStorage.removeItem(USER_PREFERENCES_KEY);
169-
} catch (prefsError) {
170-
console.warn("Failed to clear user preferences during logout:", prefsError);
171-
}
172-
// Reset the persisted data region so the next user is prompted via the
173-
// login-screen picker rather than silently inheriting this session's
174-
// region (the extension background worker clears `cal_region` on logout
175-
// too — keep the two surfaces in sync).
176-
try {
177-
await clearRegion();
178-
} catch (regionError) {
179-
console.warn("Failed to clear data region during logout:", regionError);
180-
}
181-
// Clear all cached queries to ensure fresh data on re-login
182-
try {
183-
await clearQueryCache();
184-
} catch (cacheError) {
185-
console.warn("Failed to clear query cache during logout:", cacheError);
186-
}
187-
resetAuthState();
188-
} catch (error) {
189-
const message = getErrorMessage(error);
190-
console.error("Failed to logout", message);
203+
} catch (clearAuthError) {
204+
const message = getErrorMessage(clearAuthError);
205+
console.warn("Failed to clear auth tokens during logout:", message);
191206
if (__DEV__) {
192-
console.debug("[AuthContext] logout failed", { message, stack: getErrorStack(error) });
207+
console.debug("[AuthContext] clearAuth failed", {
208+
message,
209+
stack: getErrorStack(clearAuthError),
210+
});
193211
}
194212
}
195-
}, [clearAuth, resetAuthState]);
213+
// Clear user preferences to ensure fresh state for next user
214+
try {
215+
await generalStorage.removeItem(USER_PREFERENCES_KEY);
216+
} catch (prefsError) {
217+
console.warn("Failed to clear user preferences during logout:", prefsError);
218+
}
219+
// Reset the persisted data region so the next user is prompted via the
220+
// login-screen picker rather than silently inheriting this session's
221+
// region (the extension background worker clears `cal_region` on logout
222+
// too — keep the two surfaces in sync).
223+
try {
224+
await clearRegion();
225+
} catch (regionError) {
226+
console.warn("Failed to clear data region during logout:", regionError);
227+
}
228+
// Memory first, then disk. PersistQueryClient throttles persists
229+
// (cache.config.ts: throttleTime = 1000ms); clearing the in-memory cache
230+
// before the disk wipe prevents an in-flight persist from re-writing the
231+
// previous user's data right after we erased it.
232+
try {
233+
queryClient.clear();
234+
} catch (clearError) {
235+
console.warn("Failed to clear in-memory query cache during logout:", clearError);
236+
}
237+
try {
238+
await clearQueryCache();
239+
} catch (cacheError) {
240+
console.warn("Failed to clear persisted query cache during logout:", cacheError);
241+
}
242+
// Wipe the home-screen widget so the previous user's meetings don't
243+
// linger after sign-out (no-op on web).
244+
try {
245+
await clearWidgetBookings();
246+
} catch (widgetError) {
247+
console.warn("Failed to clear widget bookings during logout:", widgetError);
248+
}
249+
resetAuthState();
250+
}, [clearAuth, resetAuthState, queryClient]);
196251

197252
// Handle OAuth authentication. `service` is passed explicitly so the boot
198253
// effect can hand in a freshly-rebuilt service on cold-start region

apps/mobile/hooks/useWidgetSync.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useQueryClient } from "@tanstack/react-query";
22
import { useCallback, useEffect } from "react";
33
import { Platform } from "react-native";
44
import { queryKeys } from "@/config/cache.config";
5+
import { useAuth } from "@/contexts/AuthContext";
56
import { type Booking, CalComAPIService } from "@/services/calcom";
67
import {
78
clearWidgetBookings,
@@ -11,6 +12,7 @@ import {
1112

1213
export function useWidgetSync() {
1314
const queryClient = useQueryClient();
15+
const { isAuthenticated } = useAuth();
1416

1517
const syncBookingsToWidget = useCallback(async () => {
1618
if (__DEV__) {
@@ -140,13 +142,19 @@ export function useWidgetSync() {
140142
if (Platform.OS === "web") {
141143
return;
142144
}
145+
// Skip the initial sync (and the API fallback inside syncBookingsToWidget)
146+
// until auth is verified — otherwise the hook fires on every cold start
147+
// before tokens are loaded and triggers an unauthenticated /bookings request.
148+
if (!isAuthenticated) {
149+
return;
150+
}
143151

144152
syncBookingsToWidget();
145153

146154
const cleanup = setupWidgetRefreshOnAppStateChange(syncBookingsToWidget);
147155

148156
return cleanup;
149-
}, [syncBookingsToWidget]);
157+
}, [syncBookingsToWidget, isAuthenticated]);
150158

151159
return {
152160
syncBookingsToWidget,

apps/mobile/utils/queryPersister.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
88
import { CACHE_CONFIG } from "@/config/cache.config";
99
import { safeLogWarn } from "./safeLogger";
10-
import { generalStorage } from "./storage";
10+
import { generalStorage, secureStorage } from "./storage";
1111

1212
// Use the shared general storage adapter for cache persistence
1313
const storage = generalStorage;
1414

15+
// Duplicated from AuthContext to avoid a circular import; keep these in sync.
16+
// If a persisted cache is found but no tokens exist, the user is logged out —
17+
// don't rehydrate a previous user's data into an unauthenticated session.
18+
const OAUTH_TOKENS_KEY = "cal_oauth_tokens";
19+
1520
/**
1621
* Create a React Query persister that works across all platforms
1722
*
@@ -44,6 +49,25 @@ export const createQueryPersister = (): Persister => {
4449
*/
4550
restoreClient: async (): Promise<PersistedClient | undefined> => {
4651
try {
52+
// Bail early if the user is logged out — never restore another user's
53+
// cache into an unauthenticated session. Wipe the orphaned cache too
54+
// so a stale logout (e.g. one where queryClient.clear() succeeded but
55+
// the throttled disk persist ran later) doesn't survive a cold start.
56+
let hasTokens = false;
57+
try {
58+
const tokens = await secureStorage.get(OAUTH_TOKENS_KEY);
59+
hasTokens = Boolean(tokens);
60+
} catch (tokenError) {
61+
safeLogWarn(
62+
"[QueryPersister] Failed to read auth tokens, treating as logged out:",
63+
tokenError
64+
);
65+
}
66+
if (!hasTokens) {
67+
await storage.removeItem(storageKey);
68+
return undefined;
69+
}
70+
4771
const serialized = await storage.getItem(storageKey);
4872
if (!serialized) {
4973
return undefined;

0 commit comments

Comments
 (0)