Compare commits

...

3 Commits

Author SHA1 Message Date
bymyself
d7c3acf8c7 fix: add jitter, toasts, and AbortController to workspace token refresh
- Add jitter to retry delays to prevent thundering herd on backend recovery
- Add toast notifications for retry states (retrying, degraded, expired)
- Add AbortController for cleanup on destroy() and clearWorkspaceContext()
- Preserve workspace context when token still valid but refresh fails
- Update tests for new behavior (jitter-aware delays, context preservation)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 13:08:08 -07:00
Christian Byrne
ee928fff8c Merge branch 'main' into fix/be-186-session-logout 2026-05-04 14:43:30 -07:00
dante01yoon
512bcde42a fix: preserve workspace session during token refresh failures 2026-04-10 13:08:09 +09:00
3 changed files with 225 additions and 60 deletions

View File

@@ -3664,7 +3664,13 @@
"accessDenied": "You do not have access to this workspace",
"workspaceNotFound": "Workspace not found",
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
},
"refreshRetrying": "Reconnecting...",
"refreshRetryingDetail": "Attempt {attempt}, retrying in {delay}s",
"refreshDegraded": "Connection issue",
"refreshDegradedDetail": "Using cached session. Will retry automatically.",
"sessionExpired": "Session expired",
"sessionExpiredDetail": "Please sign in again to continue."
},
"nightly": {
"badge": {

View File

@@ -26,6 +26,12 @@ vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: true }))
vi.mock('@/composables/useFeatureFlags', () => ({
@@ -580,6 +586,74 @@ describe('useWorkspaceAuthStore', () => {
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(workspaceToken.value).toBe('refreshed-token')
})
it('keeps the current workspace token when refresh auth fails before expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
mockGetIdToken.mockResolvedValue(undefined)
const refreshPromise = store.refreshToken()
// Advance enough for retries with jitter.
await vi.advanceTimersByTimeAsync(10_000)
await refreshPromise
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('workspace-token-abc')
// Only 1 fetch (initial switchWorkspace) - retries fail at getIdToken before fetch.
expect(mockFetch).toHaveBeenCalledTimes(1)
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
...mockTokenResponse,
token: 'recovered-token'
})
})
await vi.advanceTimersByTimeAsync(60_000)
await vi.waitFor(() => {
expect(workspaceToken.value).toBe('recovered-token')
})
})
it('clears workspace context when refresh keeps failing after token expiry', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
)
const store = useWorkspaceAuthStore()
const { currentWorkspace, workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
vi.setSystemTime(Date.now() + 2 * 60 * 60 * 1000)
mockGetIdToken.mockResolvedValue(undefined)
const refreshPromise = store.refreshToken()
// Advance enough for retries with jitter (~1500 + 2500 + 4500 = 8500ms worst case).
await vi.advanceTimersByTimeAsync(10_000)
await refreshPromise
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
})
})
describe('isAuthenticated computed', () => {
@@ -671,14 +745,10 @@ describe('useWorkspaceAuthStore', () => {
})
describe('refreshToken retry/race paths', () => {
// NOTE: This test documents the CURRENT behavior — exhausted refresh
// retries clear the workspace context unconditionally, even when the
// existing workspace token is still within its expiry window. That is a
// UX gap (transient backend outage manifests as forced logout) and the
// store should preserve a still-valid token across transient
// TOKEN_EXCHANGE_FAILED errors. Update the assertion alongside any source
// change that tracks token expiry to skip the context clear.
it('retries up to 3 times with exponential backoff on TOKEN_EXCHANGE_FAILED, then clears context', async () => {
// When refresh exhausts retries but the token is still valid, the store
// preserves context in a "degraded" state and schedules a later retry.
// This prevents transient backend outages from forcing logout.
it('retries up to 3 times with exponential backoff on TOKEN_EXCHANGE_FAILED, keeps context when token still valid', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
// Initial successful switchWorkspace establishes context.
@@ -689,7 +759,7 @@ describe('useWorkspaceAuthStore', () => {
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
const { currentWorkspace, workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(currentWorkspace.value).not.toBeNull()
@@ -702,46 +772,33 @@ describe('useWorkspaceAuthStore', () => {
json: () => Promise.resolve({ message: 'Server error' })
})
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
const refreshPromise = store.refreshToken()
// Drain the four attempts (initial + 3 retries) and their backoff delays.
await vi.runAllTimersAsync()
// Advance through retry delays (with jitter up to ~500ms each):
// attempt 0: ~1000-1500ms, attempt 1: ~2000-2500ms, attempt 2: ~4000-4500ms
await vi.advanceTimersByTimeAsync(8000)
await refreshPromise
// 1 initial switchWorkspace + 4 refresh attempts = 5 total fetch calls.
expect(mockFetch).toHaveBeenCalledTimes(5)
// Backoff: 1s + 2s + 4s = 7s of cumulative warn-logged delays.
expect(
consoleWarnSpy.mock.calls.some((c) =>
/retrying in 1000ms/.test(String(c[0]))
)
).toBe(true)
expect(
consoleWarnSpy.mock.calls.some((c) =>
/retrying in 2000ms/.test(String(c[0]))
)
).toBe(true)
expect(
consoleWarnSpy.mock.calls.some((c) =>
/retrying in 4000ms/.test(String(c[0]))
)
).toBe(true)
// Backoff with jitter logged.
const retryLogCalls = consoleWarnSpy.mock.calls.filter((c) =>
/retrying in \d+ms/.test(String(c[0]))
)
expect(retryLogCalls.length).toBe(3)
// After the final failure the context is cleared.
expect(currentWorkspace.value).toBeNull()
// Token still valid, so context is preserved (degraded state).
expect(currentWorkspace.value).not.toBeNull()
expect(workspaceToken.value).toBe('workspace-token-abc')
consoleErrorSpy.mockRestore()
consoleWarnSpy.mockRestore()
})
it('clears context immediately on INVALID_FIREBASE_TOKEN without retrying', async () => {
it('retries on INVALID_FIREBASE_TOKEN and keeps context when token still valid', async () => {
mockGetIdToken.mockResolvedValue('firebase-token-xyz')
const mockFetch = vi.fn().mockResolvedValueOnce({
ok: true,
@@ -750,12 +807,12 @@ describe('useWorkspaceAuthStore', () => {
vi.stubGlobal('fetch', mockFetch)
const store = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(store)
const { currentWorkspace, workspaceToken } = storeToRefs(store)
await store.switchWorkspace('workspace-123')
expect(currentWorkspace.value).not.toBeNull()
// Permanent error: 401 → INVALID_FIREBASE_TOKEN.
// INVALID_FIREBASE_TOKEN is retryable (Firebase token may just need refresh).
mockFetch.mockResolvedValue({
ok: false,
status: 401,
@@ -763,17 +820,22 @@ describe('useWorkspaceAuthStore', () => {
json: () => Promise.resolve({ message: 'Invalid token' })
})
const consoleErrorSpy = vi
.spyOn(console, 'error')
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
await store.refreshToken()
const refreshPromise = store.refreshToken()
// Advance through retry delays with jitter.
await vi.advanceTimersByTimeAsync(8000)
await refreshPromise
// Initial + exactly one refresh attempt; no retries on permanent errors.
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(currentWorkspace.value).toBeNull()
// 1 initial + 4 refresh attempts (initial + 3 retries) = 5 total.
expect(mockFetch).toHaveBeenCalledTimes(5)
// Token still valid, so context preserved (degraded state).
expect(currentWorkspace.value).not.toBeNull()
expect(workspaceToken.value).toBe('workspace-token-abc')
consoleErrorSpy.mockRestore()
consoleWarnSpy.mockRestore()
})
// KNOWN BUG (.fails): when an in-flight refresh's switchWorkspace call is

View File

@@ -13,6 +13,7 @@ import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/workspaceTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useToastStore } from '@/platform/updates/common/toastStore'
const WorkspaceWithRoleSchema = z.object({
id: z.string(),
@@ -49,12 +50,16 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
// State
const currentWorkspace = shallowRef<WorkspaceWithRole | null>(null)
const workspaceToken = ref<string | null>(null)
const workspaceTokenExpiresAt = ref<number | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
// Timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
// AbortController for cancelling in-flight refresh operations
let currentRefreshAbort: AbortController | null = null
// Request ID to prevent stale refresh operations from overwriting newer workspace contexts
let refreshRequestId = 0
@@ -71,6 +76,13 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
function abortCurrentRefresh(): void {
if (currentRefreshAbort) {
currentRefreshAbort.abort()
currentRefreshAbort = null
}
}
function scheduleTokenRefresh(expiresAt: number): void {
stopRefreshTimer()
const now = Date.now()
@@ -82,6 +94,35 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}, delay)
}
function hasUsableWorkspaceToken(): boolean {
return (
workspaceToken.value !== null &&
workspaceTokenExpiresAt.value !== null &&
workspaceTokenExpiresAt.value > Date.now()
)
}
function scheduleRefreshRetry(): void {
stopRefreshTimer()
if (!hasUsableWorkspaceToken() || workspaceTokenExpiresAt.value === null) {
clearWorkspaceContext()
return
}
const remainingMs = workspaceTokenExpiresAt.value - Date.now()
// Add jitter to prevent thundering herd across browser tabs
const jitter = Math.random() * 5000
const retryDelay =
remainingMs <= 10_000
? remainingMs
: Math.min(60_000, Math.floor(remainingMs / 2)) + jitter
refreshTimerId = setTimeout(() => {
void refreshToken()
}, retryDelay)
}
function persistToSession(
workspace: WorkspaceWithRole,
token: string,
@@ -119,6 +160,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
function destroy(): void {
stopRefreshTimer()
abortCurrentRefresh()
}
function initializeFromSession(): boolean {
@@ -155,6 +197,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
currentWorkspace.value = parseResult.data
workspaceToken.value = token
workspaceTokenExpiresAt.value = expiresAt
error.value = null
scheduleTokenRefresh(expiresAt)
@@ -259,6 +302,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
currentWorkspace.value = workspaceWithRole
workspaceToken.value = data.token
workspaceTokenExpiresAt.value = expiresAt
persistToSession(workspaceWithRole, data.token, expiresAt)
scheduleTokenRefresh(expiresAt)
@@ -280,10 +324,19 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
const capturedRequestId = refreshRequestId
const maxRetries = 3
const baseDelayMs = 1000
const toastStore = useToastStore()
// Create AbortController for this refresh operation
abortCurrentRefresh()
const abortController = new AbortController()
currentRefreshAbort = abortController
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Check if workspace context changed since refresh started (user switched workspaces)
if (capturedRequestId !== refreshRequestId) {
if (
capturedRequestId !== refreshRequestId ||
abortController.signal.aborted
) {
console.warn(
'Aborting stale token refresh: workspace context changed during refresh'
)
@@ -296,46 +349,86 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
} catch (err) {
const isAuthError = err instanceof WorkspaceAuthError
const isPermanentError =
const isWorkspaceAccessRevoked =
isAuthError &&
(err.code === 'ACCESS_DENIED' ||
err.code === 'WORKSPACE_NOT_FOUND' ||
err.code === 'INVALID_FIREBASE_TOKEN' ||
err.code === 'NOT_AUTHENTICATED')
(err.code === 'ACCESS_DENIED' || err.code === 'WORKSPACE_NOT_FOUND')
if (isPermanentError) {
// Only clear context if this refresh is still for the current workspace
if (isWorkspaceAccessRevoked) {
if (capturedRequestId === refreshRequestId) {
console.error('Workspace access revoked or auth invalid:', err)
clearWorkspaceContext()
toastStore.add({
severity: 'error',
summary: t('workspaceAuth.errors.accessDenied'),
life: 10000
})
}
return
}
const isTransientError =
isAuthError && err.code === 'TOKEN_EXCHANGE_FAILED'
const shouldRetryImmediately =
attempt < maxRetries &&
(!isAuthError ||
err.code === 'TOKEN_EXCHANGE_FAILED' ||
err.code === 'INVALID_FIREBASE_TOKEN' ||
err.code === 'NOT_AUTHENTICATED')
if (isTransientError && attempt < maxRetries) {
const delay = baseDelayMs * Math.pow(2, attempt)
if (shouldRetryImmediately) {
// Add jitter to prevent thundering herd
const jitter = Math.random() * 500
const delay = baseDelayMs * Math.pow(2, attempt) + jitter
console.warn(
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms:`,
err
)
toastStore.add({
severity: 'warn',
summary: t('workspaceAuth.refreshRetrying'),
detail: t('workspaceAuth.refreshRetryingDetail', {
attempt: attempt + 1,
delay: Math.round(delay / 1000)
}),
life: delay + 2000
})
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
// Only clear context if this refresh is still for the current workspace
if (
capturedRequestId === refreshRequestId &&
hasUsableWorkspaceToken()
) {
console.warn(
'Workspace token refresh failed, keeping current token until expiry:',
err
)
toastStore.add({
severity: 'warn',
summary: t('workspaceAuth.refreshDegraded'),
detail: t('workspaceAuth.refreshDegradedDetail'),
life: 10000
})
scheduleRefreshRetry()
return
}
if (capturedRequestId === refreshRequestId) {
console.error('Failed to refresh workspace token after retries:', err)
toastStore.add({
severity: 'error',
summary: t('workspaceAuth.sessionExpired'),
detail: t('workspaceAuth.sessionExpiredDetail'),
life: 10000
})
clearWorkspaceContext()
}
return
}
}
}
function getWorkspaceAuthHeader(): AuthHeader | null {
if (!workspaceToken.value) {
if (!hasUsableWorkspaceToken()) {
return null
}
return {
@@ -344,15 +437,19 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
function getWorkspaceToken(): string | undefined {
return workspaceToken.value ?? undefined
return hasUsableWorkspaceToken()
? (workspaceToken.value ?? undefined)
: undefined
}
function clearWorkspaceContext(): void {
// Increment request ID to invalidate any in-flight stale refresh operations
refreshRequestId++
abortCurrentRefresh()
stopRefreshTimer()
currentWorkspace.value = null
workspaceToken.value = null
workspaceTokenExpiresAt.value = null
error.value = null
clearSessionStorage()
}