Compare commits

...

1 Commits

Author SHA1 Message Date
dante01yoon
512bcde42a fix: preserve workspace session during token refresh failures 2026-04-10 13:08:09 +09:00
2 changed files with 123 additions and 13 deletions

View File

@@ -580,6 +580,71 @@ 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()
await vi.advanceTimersByTimeAsync(7_000)
await refreshPromise
expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole)
expect(workspaceToken.value).toBe('workspace-token-abc')
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()
await vi.advanceTimersByTimeAsync(7_000)
await refreshPromise
expect(currentWorkspace.value).toBeNull()
expect(workspaceToken.value).toBeNull()
})
})
describe('isAuthenticated computed', () => {

View File

@@ -49,6 +49,7 @@ 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)
@@ -82,6 +83,33 @@ 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()
const retryDelay =
remainingMs <= 10_000
? remainingMs
: Math.min(60_000, Math.floor(remainingMs / 2))
refreshTimerId = setTimeout(() => {
void refreshToken()
}, retryDelay)
}
function persistToSession(
workspace: WorkspaceWithRole,
token: string,
@@ -155,6 +183,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
currentWorkspace.value = parseResult.data
workspaceToken.value = token
workspaceTokenExpiresAt.value = expiresAt
error.value = null
scheduleTokenRefresh(expiresAt)
@@ -259,6 +288,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
currentWorkspace.value = workspaceWithRole
workspaceToken.value = data.token
workspaceTokenExpiresAt.value = expiresAt
persistToSession(workspaceWithRole, data.token, expiresAt)
scheduleTokenRefresh(expiresAt)
@@ -296,15 +326,11 @@ 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()
@@ -312,10 +338,14 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
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) {
if (shouldRetryImmediately) {
const delay = baseDelayMs * Math.pow(2, attempt)
console.warn(
`Token refresh failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms:`,
@@ -325,17 +355,29 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
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
)
scheduleRefreshRetry()
return
}
if (capturedRequestId === refreshRequestId) {
console.error('Failed to refresh workspace token after retries:', err)
clearWorkspaceContext()
}
return
}
}
}
function getWorkspaceAuthHeader(): AuthHeader | null {
if (!workspaceToken.value) {
if (!hasUsableWorkspaceToken()) {
return null
}
return {
@@ -344,7 +386,9 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
function getWorkspaceToken(): string | undefined {
return workspaceToken.value ?? undefined
return hasUsableWorkspaceToken()
? (workspaceToken.value ?? undefined)
: undefined
}
function clearWorkspaceContext(): void {
@@ -353,6 +397,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
stopRefreshTimer()
currentWorkspace.value = null
workspaceToken.value = null
workspaceTokenExpiresAt.value = null
error.value = null
clearSessionStorage()
}