mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+67
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user