Skip to content

Commit bfe8386

Browse files
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
1 parent e2ea49e commit bfe8386

7 files changed

Lines changed: 145 additions & 27 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { Chip } from '@sim/emcn'
5+
import { useSession } from '@/lib/auth/auth-client'
6+
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
7+
8+
/**
9+
* Cleanly logs out when an impersonation session expires while the app is
10+
* mounted.
11+
*
12+
* Detection keys off the transition: this only activates when the session
13+
* query settles to `null` after a session that carried `impersonatedBy` (the
14+
* expired session is deleted server-side, so the live session can't be used).
15+
*
16+
* Recovery goes through {@link recoverFromStaleSession}: the expired session's
17+
* cookies are never cleared server-side (better-auth's customSession plugin
18+
* swallows the deletion headers), and the login route bounces any request
19+
* still carrying a session cookie back to /workspace. The helper signs out
20+
* (which clears the cookies without requiring a live session), wipes per-user
21+
* client state, and only navigates when the sign-out succeeded — navigating
22+
* after a failed sign-out would recreate the redirect loop.
23+
*/
24+
export function ImpersonationExpired() {
25+
const { data: session, isPending, error } = useSession()
26+
const startedRef = useRef(false)
27+
const [sawImpersonation, setSawImpersonation] = useState(false)
28+
const [failed, setFailed] = useState(false)
29+
30+
if (!sawImpersonation && session?.session?.impersonatedBy) {
31+
setSawImpersonation(true)
32+
}
33+
34+
const expired = sawImpersonation && !isPending && !error && !session?.user
35+
36+
const attemptRecovery = useCallback(() => {
37+
setFailed(false)
38+
void recoverFromStaleSession().then((recovered) => {
39+
if (!recovered) setFailed(true)
40+
})
41+
}, [])
42+
43+
useEffect(() => {
44+
if (!expired || startedRef.current) return
45+
startedRef.current = true
46+
attemptRecovery()
47+
}, [expired, attemptRecovery])
48+
49+
if (!expired) {
50+
return null
51+
}
52+
53+
return (
54+
<main className='fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-[var(--surface-1)] p-6'>
55+
<p className='text-[var(--text-muted)] text-sm'>
56+
{failed
57+
? 'The impersonation session expired, but signing out failed.'
58+
: 'The impersonation session expired. Signing you out…'}
59+
</p>
60+
{failed && (
61+
<Chip variant='primary' onClick={attemptRecovery}>
62+
Try again
63+
</Chip>
64+
)}
65+
</main>
66+
)
67+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { ImpersonationBanner } from './impersonation-banner'
2+
export { ImpersonationExpired } from './impersonation-expired'

apps/sim/app/workspace/[workspaceId]/layout.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({
5959

6060
vi.mock('@/app/workspace/[workspaceId]/components/impersonation-banner', () => ({
6161
ImpersonationBanner: () => null,
62+
ImpersonationExpired: () => null,
6263
}))
6364

6465
vi.mock('@/app/workspace/[workspaceId]/components/workspace-chrome', () => ({

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { cookies } from 'next/headers'
44
import { redirect } from 'next/navigation'
55
import { getSession } from '@/lib/auth'
66
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
7-
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
7+
import {
8+
ImpersonationBanner,
9+
ImpersonationExpired,
10+
} from '@/app/workspace/[workspaceId]/components/impersonation-banner'
811
import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied'
912
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
1013
import {
@@ -66,6 +69,7 @@ export default async function WorkspaceLayout({
6669
<GlobalCommandsProvider>
6770
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
6871
<ImpersonationBanner />
72+
<ImpersonationExpired />
6973
<WorkspacePermissionsProvider>
7074
<WorkspaceScopeSync />
7175
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>

apps/sim/app/workspace/page.tsx

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import { isApiClientError } from '@/lib/api/client/errors'
99
import { requestJson } from '@/lib/api/client/request'
1010
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
1111
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
12-
import { signOut, useSession } from '@/lib/auth/auth-client'
12+
import { useSession } from '@/lib/auth/auth-client'
13+
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
1314
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
1415
import { useWorkspacesWithMetadata, type WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
15-
import { clearUserData } from '@/stores'
1616

1717
const logger = createLogger('WorkspacePage')
1818

@@ -27,24 +27,6 @@ function isStaleSessionError(error: unknown): boolean {
2727
return isApiClientError(error) && error.status === 401
2828
}
2929

30-
/**
31-
* Signs out (clearing every auth cookie server-side), wipes per-user client
32-
* state, and navigates to login. Returns false without navigating when the
33-
* sign-out request fails — the cookies are still set, so going to /login
34-
* would only get bounced back to /workspace by the middleware.
35-
*/
36-
async function recoverFromStaleSession(): Promise<boolean> {
37-
try {
38-
await signOut()
39-
} catch (error) {
40-
logger.error('Failed to sign out while recovering from a stale session:', error)
41-
return false
42-
}
43-
await clearUserData()
44-
window.location.assign('/login')
45-
return true
46-
}
47-
4830
export default function WorkspacePage() {
4931
const router = useRouter()
5032
const { data: session, isPending: isSessionPending, error: sessionError } = useSession()
@@ -77,8 +59,17 @@ export default function WorkspacePage() {
7759
// Indeterminate auth (errored session query, no cached identity): show
7860
// the error card — /login would bounce back while a session cookie exists.
7961
if (sessionError) return
80-
logger.info('User not authenticated, redirecting to login')
81-
router.replace('/login')
62+
// A clean null session can still have stale auth cookies behind it (an
63+
// expired impersonation session's cookies are never cleared server-side),
64+
// and the middleware bounces /login back here while any session cookie
65+
// exists — a bare replace('/login') loops forever on the spinner. Recover
66+
// the same way as the 401 path: sign out (clears the cookies without
67+
// needing a live session), then navigate.
68+
hasRedirectedRef.current = true
69+
logger.info('User not authenticated, signing out stale cookies and redirecting to login')
70+
void recoverFromStaleSession().then((recovered) => {
71+
if (!recovered) setRecoveryFailed(true)
72+
})
8273
return
8374
}
8475

apps/sim/hooks/queries/session.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type QueryClient, useQuery } from '@tanstack/react-query'
1+
import { type QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'
22
import { client } from '@/lib/auth/auth-client'
33
import {
44
type AppSession,
@@ -12,8 +12,14 @@ export const sessionKeys = {
1212
detail: () => [...sessionKeys.all, 'detail'] as const,
1313
}
1414

15-
async function fetchSession(signal?: AbortSignal): Promise<AppSession> {
16-
const res = await client.getSession({ fetchOptions: { signal } })
15+
async function fetchSession(
16+
signal?: AbortSignal,
17+
disableCookieCache?: boolean
18+
): Promise<AppSession> {
19+
const res = await client.getSession({
20+
...(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
21+
fetchOptions: { signal },
22+
})
1723
return extractSessionDataFromAuthClientResult(res) as AppSession
1824
}
1925

@@ -35,6 +41,8 @@ export async function refreshSessionQuery(queryClient: QueryClient): Promise<App
3541
return fresh
3642
}
3743

44+
export const IMPERSONATION_REFETCH_INTERVAL = 60 * 1000
45+
3846
/**
3947
* Reads the current Better Auth session via the client SDK.
4048
*
@@ -44,12 +52,29 @@ export async function refreshSessionQuery(queryClient: QueryClient): Promise<App
4452
* `retry: false` preserves the prior fail-fast contract: an auth failure (expired
4553
* token, startup network partition) surfaces immediately rather than retrying a
4654
* request that won't succeed.
55+
*
56+
* While the session is an impersonation session, the query polls and refetches
57+
* on focus (overriding the global `refetchOnWindowFocus: false`) so an expiry —
58+
* including one slept through with the laptop closed — settles the query to
59+
* `null` and surfaces the impersonation-expired recovery screen. Those
60+
* refetches also bypass Better Auth's cookie cache: it can otherwise keep
61+
* vouching for a session that was expired or revoked server-side, and the
62+
* expiry detection shouldn't depend on the cache's own TTL details.
63+
* Impersonation sessions are short-lived and admin-only, so none of these
64+
* overrides affect normal sessions.
4765
*/
4866
export function useSessionQuery() {
67+
const queryClient = useQueryClient()
4968
return useQuery({
5069
queryKey: sessionKeys.detail(),
51-
queryFn: ({ signal }) => fetchSession(signal),
70+
queryFn: ({ signal }) => {
71+
const cached = queryClient.getQueryData<AppSession>(sessionKeys.detail())
72+
return fetchSession(signal, Boolean(cached?.session?.impersonatedBy))
73+
},
5274
staleTime: SESSION_STALE_TIME,
5375
retry: false,
76+
refetchInterval: (query) =>
77+
query.state.data?.session?.impersonatedBy ? IMPERSONATION_REFETCH_INTERVAL : false,
78+
refetchOnWindowFocus: (query) => Boolean(query.state.data?.session?.impersonatedBy),
5479
})
5580
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client'
2+
3+
import { createLogger } from '@sim/logger'
4+
import { signOut } from '@/lib/auth/auth-client'
5+
import { clearUserData } from '@/stores'
6+
7+
const logger = createLogger('StaleSessionRecovery')
8+
9+
/**
10+
* Signs out (clearing every auth cookie server-side), wipes per-user client
11+
* state, and navigates to login. Returns false without navigating when the
12+
* sign-out request fails — the cookies are still set, so going to /login
13+
* would only get bounced back to /workspace by the middleware.
14+
*
15+
* Shared by the /workspace loader (stale-cookie 401s and clean-null sessions)
16+
* and the impersonation-expired screen, so every identity-recovery path clears
17+
* cookies and persisted client state the same way.
18+
*/
19+
export async function recoverFromStaleSession(): Promise<boolean> {
20+
try {
21+
await signOut()
22+
} catch (error) {
23+
logger.error('Failed to sign out while recovering from a stale session:', error)
24+
return false
25+
}
26+
await clearUserData()
27+
window.location.assign('/login')
28+
return true
29+
}

0 commit comments

Comments
 (0)