fix(auth): recover cleanly when an impersonation session expires (#5692)

* fix(auth): recover cleanly when an impersonation session expires

* fix(auth): share stale-session recovery and bypass cookie cache in impersonation poll
This commit is contained in:
Theodore Li
2026-07-15 15:44:45 -04:00
committed by GitHub
parent e2ea49ea7e
commit bfe83869e8
7 changed files with 145 additions and 27 deletions
@@ -0,0 +1,67 @@
'use 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
* 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).
*
* Recovery goes through {@link recoverFromStaleSession}: the expired session's
* cookies are never cleared server-side (better-auth's customSession plugin
* 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)
}
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
attemptRecovery()
}, [expired, attemptRecovery])
if (!expired) {
return null
}
return (
<main className='fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-[var(--surface-1)] p-6'>
<p className='text-[var(--text-muted)] text-sm'>
{failed
? 'The impersonation session expired, but signing out failed.'
: 'The impersonation session expired. Signing you out…'}
</p>
{failed && (
<Chip variant='primary' onClick={attemptRecovery}>
Try again
</Chip>
)}
</main>
)
}
@@ -1 +1,2 @@
export { ImpersonationBanner } from './impersonation-banner'
export { ImpersonationExpired } from './impersonation-expired'
@@ -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', () => ({
@@ -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({
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<ImpersonationExpired />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
+13 -22
View File
@@ -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<boolean> {
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()
@@ -77,8 +59,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
}
+29 -4
View File
@@ -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<AppSession> {
const res = await client.getSession({ fetchOptions: { signal } })
async function fetchSession(
signal?: AbortSignal,
disableCookieCache?: boolean
): Promise<AppSession> {
const res = await client.getSession({
...(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
fetchOptions: { signal },
})
return extractSessionDataFromAuthClientResult(res) as AppSession
}
@@ -35,6 +41,8 @@ export async function refreshSessionQuery(queryClient: QueryClient): Promise<App
return fresh
}
export const IMPERSONATION_REFETCH_INTERVAL = 60 * 1000
/**
* Reads the current Better Auth session via the client SDK.
*
@@ -44,12 +52,29 @@ export async function refreshSessionQuery(queryClient: QueryClient): Promise<App
* `retry: false` preserves the prior fail-fast contract: an auth failure (expired
* token, startup network partition) surfaces immediately rather than retrying a
* request that won't succeed.
*
* 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. 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<AppSession>(sessionKeys.detail())
return fetchSession(signal, Boolean(cached?.session?.impersonatedBy))
},
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),
})
}
@@ -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<boolean> {
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
}