Skip to content

Commit a1d96f1

Browse files
fix(ui): scope active-devices cache by user to prevent cross-user leak (#8703)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent be55c4e commit a1d96f1

3 files changed

Lines changed: 58 additions & 1 deletion

File tree

.changeset/tangy-ducks-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
Scope the `UserProfile` active-devices fetch cache by `user.id` so a session switch or sign-out/sign-in on a shared device no longer renders the previous user's device activity (IP, location, browser/device) from the module-scoped cache.

packages/ui/src/components/UserProfile/ActiveDevicesSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const ActiveDevicesSection = () => {
1717
const { user } = useUser();
1818
const { session } = useSession();
1919

20-
const { data: sessions, isLoading } = useFetch(user?.getSessions, 'user-sessions');
20+
const { data: sessions, isLoading } = useFetch(user?.getSessions, { userId: user?.id }, undefined, 'user-sessions');
2121

2222
return (
2323
<ProfileSection.Root

packages/ui/src/components/UserProfile/__tests__/SecurityPage.test.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,4 +366,56 @@ describe('SecurityPage', () => {
366366
}
367367
});
368368
});
369+
370+
it('does not leak the previous user device activity across a user switch', async () => {
371+
const makeSession = (sessionId: string, city: string) =>
372+
({
373+
pathRoot: '/me/sessions',
374+
id: sessionId,
375+
status: 'active',
376+
expireAt: '2022-12-01T01:55:44.636Z',
377+
abandonAt: '2022-12-24T01:55:44.636Z',
378+
lastActiveAt: '2022-11-24T12:11:49.328Z',
379+
latestActivity: {
380+
id: 'sess_activity_1',
381+
deviceType: 'Macintosh',
382+
browserName: 'Chrome',
383+
browserVersion: '107.0.0.0',
384+
country: 'Greece',
385+
city,
386+
isMobile: false,
387+
},
388+
actor: null,
389+
revoke: vi.fn().mockResolvedValue({}),
390+
}) as any as SessionWithActivitiesResource;
391+
392+
// User A renders the Security page, caching their sessions in the
393+
// module-scoped `useFetch` cache.
394+
const { wrapper: wrapperA, fixtures: fixturesA } = await createFixtures(f => {
395+
f.withUser({ id: 'user_a', email_addresses: ['a@clerk.com'] });
396+
});
397+
fixturesA.clerk.user!.getSessions.mockReturnValue(
398+
Promise.resolve([makeSession(fixturesA.clerk.session!.id, 'Athens')]),
399+
);
400+
401+
const { unmount } = render(<SecurityPage />, { wrapper: wrapperA });
402+
await waitFor(() => expect(fixturesA.clerk.user?.getSessions).toHaveBeenCalled());
403+
await screen.findByText(/Athens/i);
404+
unmount();
405+
406+
// User B mounts the Security page within the stale window. Because the cache
407+
// is keyed by `user.id`, B must trigger a fresh fetch and never sees A's
408+
// cached device activity.
409+
const { wrapper: wrapperB, fixtures: fixturesB } = await createFixtures(f => {
410+
f.withUser({ id: 'user_b', email_addresses: ['b@clerk.com'] });
411+
});
412+
fixturesB.clerk.user!.getSessions.mockReturnValue(
413+
Promise.resolve([makeSession(fixturesB.clerk.session!.id, 'Berlin')]),
414+
);
415+
416+
render(<SecurityPage />, { wrapper: wrapperB });
417+
await waitFor(() => expect(fixturesB.clerk.user?.getSessions).toHaveBeenCalled());
418+
await screen.findByText(/Berlin/i);
419+
expect(screen.queryByText(/Athens/i)).not.toBeInTheDocument();
420+
});
369421
});

0 commit comments

Comments
 (0)