Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.

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>
)
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ImpersonationBanner } from './impersonation-banner'
export { ImpersonationExpired } from './impersonation-expired'
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}>
Expand Down
35 changes: 13 additions & 22 deletions apps/sim/app/workspace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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()
Expand Down Expand Up @@ -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)
})
Comment thread
TheodoreSpeaks marked this conversation as resolved.
return
}

Expand Down
33 changes: 29 additions & 4 deletions apps/sim/hooks/queries/session.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
}

Expand All @@ -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.
*
Expand All @@ -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),
Comment thread
TheodoreSpeaks marked this conversation as resolved.
})
}
29 changes: 29 additions & 0 deletions apps/sim/lib/auth/stale-session-recovery.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading