From fa07292e01964f4cb5b330f1697f7ccb7f4d4d14 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 15 Jul 2026 11:49:11 -0700 Subject: [PATCH 1/2] fix(auth): recover cleanly when an impersonation session expires --- .../impersonation-expired.tsx | 51 +++++++++++++++++++ .../components/impersonation-banner/index.ts | 1 + .../workspace/[workspaceId]/layout.test.tsx | 1 + .../app/workspace/[workspaceId]/layout.tsx | 6 ++- apps/sim/app/workspace/page.tsx | 13 ++++- apps/sim/hooks/queries/session.ts | 12 +++++ 6 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx new file mode 100644 index 00000000000..0c37dd08b43 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { signOut, useSession } from '@/lib/auth/auth-client' + +/** + * Cleanly logs out when an impersonation session expires while the app is + * mounted. + * + * Detection keys off the transition: this only activates when the session + * query settles to `null` after a session that carried `impersonatedBy` (the + * expired session is deleted server-side, so the live session can't be used). + * + * The explicit sign-out before redirecting matters: the expired session's + * cookies are never cleared server-side (better-auth's customSession plugin + * swallows the deletion headers on the null path), and the login route + * bounces any request still carrying a session cookie back to /workspace — + * an infinite loop on a blank page. /sign-out clears the cookies without + * requiring a live session. + */ +export function ImpersonationExpired() { + const { data: session, isPending, error } = useSession() + const startedRef = useRef(false) + const [sawImpersonation, setSawImpersonation] = useState(false) + + if (!sawImpersonation && session?.session?.impersonatedBy) { + setSawImpersonation(true) + } + + const expired = sawImpersonation && !isPending && !error && !session?.user + + useEffect(() => { + if (!expired || startedRef.current) return + startedRef.current = true + signOut() + .catch(() => {}) + .finally(() => window.location.assign('/login')) + }, [expired]) + + if (!expired) { + return null + } + + return ( +
+

+ The impersonation session expired. Signing you out… +

+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts index dcfa353780c..1482b93081b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts @@ -1 +1,2 @@ export { ImpersonationBanner } from './impersonation-banner' +export { ImpersonationExpired } from './impersonation-expired' diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 33beae266a0..c2bd9879894 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -59,6 +59,7 @@ vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({ vi.mock('@/app/workspace/[workspaceId]/components/impersonation-banner', () => ({ ImpersonationBanner: () => null, + ImpersonationExpired: () => null, })) vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 645496c18c9..f10df8f2883 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -4,7 +4,10 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' +import { + ImpersonationBanner, + ImpersonationExpired, +} from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { @@ -66,6 +69,7 @@ export default async function WorkspaceLayout({
+ diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 5e277d699e3..65044f6f7a9 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -77,8 +77,17 @@ export default function WorkspacePage() { // Indeterminate auth (errored session query, no cached identity): show // the error card — /login would bounce back while a session cookie exists. if (sessionError) return - logger.info('User not authenticated, redirecting to login') - router.replace('/login') + // A clean null session can still have stale auth cookies behind it (an + // expired impersonation session's cookies are never cleared server-side), + // and the middleware bounces /login back here while any session cookie + // exists — a bare replace('/login') loops forever on the spinner. Recover + // the same way as the 401 path: sign out (clears the cookies without + // needing a live session), then navigate. + hasRedirectedRef.current = true + logger.info('User not authenticated, signing out stale cookies and redirecting to login') + void recoverFromStaleSession().then((recovered) => { + if (!recovered) setRecoveryFailed(true) + }) return } diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts index add45a08854..2b191eb1e07 100644 --- a/apps/sim/hooks/queries/session.ts +++ b/apps/sim/hooks/queries/session.ts @@ -35,6 +35,8 @@ export async function refreshSessionQuery(queryClient: QueryClient): Promise fetchSession(signal), staleTime: SESSION_STALE_TIME, retry: false, + refetchInterval: (query) => + query.state.data?.session?.impersonatedBy ? IMPERSONATION_REFETCH_INTERVAL : false, + refetchOnWindowFocus: (query) => Boolean(query.state.data?.session?.impersonatedBy), }) } From d675f0b10a16c39a7319673002d5bcfc6c7d60ae Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 15 Jul 2026 12:02:56 -0700 Subject: [PATCH 2/2] fix(auth): share stale-session recovery and bypass cookie cache in impersonation poll --- .../impersonation-expired.tsx | 42 +++++++++++++------ apps/sim/app/workspace/page.tsx | 22 +--------- apps/sim/hooks/queries/session.ts | 27 ++++++++---- apps/sim/lib/auth/stale-session-recovery.ts | 29 +++++++++++++ 4 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 apps/sim/lib/auth/stale-session-recovery.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx index 0c37dd08b43..2702660d7ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-expired.tsx @@ -1,7 +1,9 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { signOut, useSession } from '@/lib/auth/auth-client' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Chip } from '@sim/emcn' +import { useSession } from '@/lib/auth/auth-client' +import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' /** * Cleanly logs out when an impersonation session expires while the app is @@ -11,17 +13,19 @@ import { signOut, useSession } from '@/lib/auth/auth-client' * query settles to `null` after a session that carried `impersonatedBy` (the * expired session is deleted server-side, so the live session can't be used). * - * The explicit sign-out before redirecting matters: the expired session's + * Recovery goes through {@link recoverFromStaleSession}: the expired session's * cookies are never cleared server-side (better-auth's customSession plugin - * swallows the deletion headers on the null path), and the login route - * bounces any request still carrying a session cookie back to /workspace — - * an infinite loop on a blank page. /sign-out clears the cookies without - * requiring a live session. + * swallows the deletion headers), and the login route bounces any request + * still carrying a session cookie back to /workspace. The helper signs out + * (which clears the cookies without requiring a live session), wipes per-user + * client state, and only navigates when the sign-out succeeded — navigating + * after a failed sign-out would recreate the redirect loop. */ export function ImpersonationExpired() { const { data: session, isPending, error } = useSession() const startedRef = useRef(false) const [sawImpersonation, setSawImpersonation] = useState(false) + const [failed, setFailed] = useState(false) if (!sawImpersonation && session?.session?.impersonatedBy) { setSawImpersonation(true) @@ -29,23 +33,35 @@ export function ImpersonationExpired() { const expired = sawImpersonation && !isPending && !error && !session?.user + const attemptRecovery = useCallback(() => { + setFailed(false) + void recoverFromStaleSession().then((recovered) => { + if (!recovered) setFailed(true) + }) + }, []) + useEffect(() => { if (!expired || startedRef.current) return startedRef.current = true - signOut() - .catch(() => {}) - .finally(() => window.location.assign('/login')) - }, [expired]) + attemptRecovery() + }, [expired, attemptRecovery]) if (!expired) { return null } return ( -
+

- The impersonation session expired. Signing you out… + {failed + ? 'The impersonation session expired, but signing out failed.' + : 'The impersonation session expired. Signing you out…'}

+ {failed && ( + + Try again + + )}
) } diff --git a/apps/sim/app/workspace/page.tsx b/apps/sim/app/workspace/page.tsx index 65044f6f7a9..6137b205d19 100644 --- a/apps/sim/app/workspace/page.tsx +++ b/apps/sim/app/workspace/page.tsx @@ -9,10 +9,10 @@ import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { getWorkflowStateContract } from '@/lib/api/contracts/workflows' import { createWorkspaceContract } from '@/lib/api/contracts/workspaces' -import { signOut, useSession } from '@/lib/auth/auth-client' +import { useSession } from '@/lib/auth/auth-client' +import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery' import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage' import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace' -import { clearUserData } from '@/stores' const logger = createLogger('WorkspacePage') @@ -27,24 +27,6 @@ function isStaleSessionError(error: unknown): boolean { return isApiClientError(error) && error.status === 401 } -/** - * Signs out (clearing every auth cookie server-side), wipes per-user client - * state, and navigates to login. Returns false without navigating when the - * sign-out request fails — the cookies are still set, so going to /login - * would only get bounced back to /workspace by the middleware. - */ -async function recoverFromStaleSession(): Promise { - try { - await signOut() - } catch (error) { - logger.error('Failed to sign out while recovering from a stale session:', error) - return false - } - await clearUserData() - window.location.assign('/login') - return true -} - export default function WorkspacePage() { const router = useRouter() const { data: session, isPending: isSessionPending, error: sessionError } = useSession() diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts index 2b191eb1e07..686a2447c7b 100644 --- a/apps/sim/hooks/queries/session.ts +++ b/apps/sim/hooks/queries/session.ts @@ -1,4 +1,4 @@ -import { type QueryClient, useQuery } from '@tanstack/react-query' +import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query' import { client } from '@/lib/auth/auth-client' import { type AppSession, @@ -12,8 +12,14 @@ export const sessionKeys = { detail: () => [...sessionKeys.all, 'detail'] as const, } -async function fetchSession(signal?: AbortSignal): Promise { - const res = await client.getSession({ fetchOptions: { signal } }) +async function fetchSession( + signal?: AbortSignal, + disableCookieCache?: boolean +): Promise { + const res = await client.getSession({ + ...(disableCookieCache ? { query: { disableCookieCache: true } } : {}), + fetchOptions: { signal }, + }) return extractSessionDataFromAuthClientResult(res) as AppSession } @@ -50,14 +56,21 @@ export const IMPERSONATION_REFETCH_INTERVAL = 60 * 1000 * While the session is an impersonation session, the query polls and refetches * on focus (overriding the global `refetchOnWindowFocus: false`) so an expiry — * including one slept through with the laptop closed — settles the query to - * `null` and surfaces the impersonation-expired recovery screen. Impersonation - * sessions are short-lived and admin-only, so neither override affects normal - * sessions. + * `null` and surfaces the impersonation-expired recovery screen. Those + * refetches also bypass Better Auth's cookie cache: it can otherwise keep + * vouching for a session that was expired or revoked server-side, and the + * expiry detection shouldn't depend on the cache's own TTL details. + * Impersonation sessions are short-lived and admin-only, so none of these + * overrides affect normal sessions. */ export function useSessionQuery() { + const queryClient = useQueryClient() return useQuery({ queryKey: sessionKeys.detail(), - queryFn: ({ signal }) => fetchSession(signal), + queryFn: ({ signal }) => { + const cached = queryClient.getQueryData(sessionKeys.detail()) + return fetchSession(signal, Boolean(cached?.session?.impersonatedBy)) + }, staleTime: SESSION_STALE_TIME, retry: false, refetchInterval: (query) => diff --git a/apps/sim/lib/auth/stale-session-recovery.ts b/apps/sim/lib/auth/stale-session-recovery.ts new file mode 100644 index 00000000000..a3b9b38f6a7 --- /dev/null +++ b/apps/sim/lib/auth/stale-session-recovery.ts @@ -0,0 +1,29 @@ +'use client' + +import { createLogger } from '@sim/logger' +import { signOut } from '@/lib/auth/auth-client' +import { clearUserData } from '@/stores' + +const logger = createLogger('StaleSessionRecovery') + +/** + * Signs out (clearing every auth cookie server-side), wipes per-user client + * state, and navigates to login. Returns false without navigating when the + * sign-out request fails — the cookies are still set, so going to /login + * would only get bounced back to /workspace by the middleware. + * + * Shared by the /workspace loader (stale-cookie 401s and clean-null sessions) + * and the impersonation-expired screen, so every identity-recovery path clears + * cookies and persisted client state the same way. + */ +export async function recoverFromStaleSession(): Promise { + try { + await signOut() + } catch (error) { + logger.error('Failed to sign out while recovering from a stale session:', error) + return false + } + await clearUserData() + window.location.assign('/login') + return true +}