-
Notifications
You must be signed in to change notification settings - Fork 561
fix: guard workspace auth refresh races #11726
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -671,14 +671,7 @@ 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 () => { | ||
| it('retries up to 3 times with exponential backoff on TOKEN_EXCHANGE_FAILED, then preserves valid context', async () => { | ||
| mockGetIdToken.mockResolvedValue('firebase-token-xyz') | ||
|
|
||
| // Initial successful switchWorkspace establishes context. | ||
|
|
@@ -689,10 +682,11 @@ describe('useWorkspaceAuthStore', () => { | |
| vi.stubGlobal('fetch', mockFetch) | ||
|
|
||
| const store = useWorkspaceAuthStore() | ||
| const { currentWorkspace } = storeToRefs(store) | ||
| const { currentWorkspace, workspaceToken, error } = storeToRefs(store) | ||
|
|
||
| await store.switchWorkspace('workspace-123') | ||
| expect(currentWorkspace.value).not.toBeNull() | ||
| expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole) | ||
| expect(workspaceToken.value).toBe('workspace-token-abc') | ||
|
|
||
| // Subsequent refresh attempts all fail with 500 (TOKEN_EXCHANGE_FAILED). | ||
| mockFetch.mockResolvedValue({ | ||
|
|
@@ -711,8 +705,11 @@ describe('useWorkspaceAuthStore', () => { | |
|
|
||
| const refreshPromise = store.refreshToken() | ||
|
|
||
| // Drain the four attempts (initial + 3 retries) and their backoff delays. | ||
| await vi.runAllTimersAsync() | ||
| // Drain only the retry backoff delays; do not advance to the scheduled | ||
| // proactive refresh timer for the still-valid token. | ||
| await vi.advanceTimersByTimeAsync(1000) | ||
| await vi.advanceTimersByTimeAsync(2000) | ||
| await vi.advanceTimersByTimeAsync(4000) | ||
| await refreshPromise | ||
|
|
||
| // 1 initial switchWorkspace + 4 refresh attempts = 5 total fetch calls. | ||
|
|
@@ -734,8 +731,32 @@ describe('useWorkspaceAuthStore', () => { | |
| ) | ||
| ).toBe(true) | ||
|
|
||
| // After the final failure the context is cleared. | ||
| expect(currentWorkspace.value).toBeNull() | ||
| // After the final transient failure the still-valid context is preserved. | ||
| expect(currentWorkspace.value).toEqual(mockWorkspaceWithRole) | ||
| expect(workspaceToken.value).toBe('workspace-token-abc') | ||
| expect( | ||
| sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE) | ||
| ).toBe(JSON.stringify(mockWorkspaceWithRole)) | ||
| expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe( | ||
| 'workspace-token-abc' | ||
| ) | ||
| expect(error.value).toBeNull() | ||
| expect(consoleErrorSpy).not.toHaveBeenCalled() | ||
|
|
||
| mockFetch.mockResolvedValueOnce({ | ||
| ok: true, | ||
| json: () => | ||
| Promise.resolve({ ...mockTokenResponse, token: 'retry-token' }) | ||
| }) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(7999) | ||
| expect(mockFetch).toHaveBeenCalledTimes(5) | ||
|
|
||
| await vi.advanceTimersByTimeAsync(1) | ||
| expect(mockFetch).toHaveBeenCalledTimes(6) | ||
| await vi.waitFor(() => { | ||
| expect(workspaceToken.value).toBe('retry-token') | ||
| }) | ||
|
|
||
| consoleErrorSpy.mockRestore() | ||
| consoleWarnSpy.mockRestore() | ||
|
|
@@ -776,16 +797,7 @@ describe('useWorkspaceAuthStore', () => { | |
| consoleErrorSpy.mockRestore() | ||
| }) | ||
|
|
||
| // KNOWN BUG (.fails): when an in-flight refresh's switchWorkspace call is | ||
| // already past its requestId-staleness check and awaiting the token-exchange | ||
| // fetch, switchWorkspace has no post-await commit guard. If the user | ||
| // switches workspaces and the stale refresh's fetch resolves AFTER the new | ||
| // switch has committed, the stale response will overwrite the new | ||
| // workspace's currentWorkspace/workspaceToken/sessionStorage. Mark this | ||
| // expected-fail until switchWorkspace gains a commit-time staleness check | ||
| // (e.g. compare captured requestId or expected workspaceId before | ||
| // assigning state). Removing `.fails` once fixed will catch regressions. | ||
| it.fails('the new workspace wins when the stale refresh resolves last', async () => { | ||
| it('the new workspace wins when the stale refresh resolves last', async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): the new catch-block ABA guard (workspaceAuthStore.ts:296-301) is not exercised by tests. This race test resolves the stale fetch with |
||
| mockGetIdToken.mockResolvedValue('firebase-token-xyz') | ||
|
|
||
| const mockFetch = vi.fn().mockResolvedValueOnce({ | ||
|
|
@@ -824,21 +836,30 @@ describe('useWorkspaceAuthStore', () => { | |
| // New workspace is committed at this point. | ||
| expect(currentWorkspace.value?.id).toBe('workspace-other') | ||
| expect(workspaceToken.value).toBe('new-workspace-token') | ||
| expect( | ||
| sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE) | ||
| ).toBe(JSON.stringify({ ...newWorkspace, role: 'owner' })) | ||
| expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe( | ||
| 'new-workspace-token' | ||
| ) | ||
|
|
||
| // Now resolve the stale refresh fetch — it carries an OLD-workspace | ||
| // token, and the source has no commit-time staleness check, so it | ||
| // clobbers the new workspace state. | ||
| // token. It must not clobber the new workspace state or sessionStorage. | ||
| resolveRefreshFetch({ | ||
| ok: true, | ||
| json: () => | ||
| Promise.resolve({ ...mockTokenResponse, token: 'stale-token' }) | ||
| }) | ||
| await refreshPromise | ||
|
|
||
| // Once the source-side guard is added, both of these become true | ||
| // (the test stops failing) and `.fails` should be dropped. | ||
| expect(currentWorkspace.value?.id).toBe('workspace-other') | ||
| expect(workspaceToken.value).toBe('new-workspace-token') | ||
| expect( | ||
| sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE) | ||
| ).toBe(JSON.stringify({ ...newWorkspace, role: 'owner' })) | ||
| expect(sessionStorage.getItem(WORKSPACE_STORAGE_KEYS.TOKEN)).toBe( | ||
| 'new-workspace-token' | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
|
|
@@ -853,27 +874,40 @@ describe('useWorkspaceAuthStore', () => { | |
| }) | ||
| ) | ||
|
|
||
| const setItemSpy = vi | ||
| .spyOn(sessionStorage, 'setItem') | ||
| .mockImplementation(() => { | ||
| const originalSessionStorage = globalThis.sessionStorage | ||
| const throwingSessionStorage = { | ||
| get length() { | ||
| return originalSessionStorage.length | ||
| }, | ||
| key: originalSessionStorage.key.bind(originalSessionStorage), | ||
| getItem: originalSessionStorage.getItem.bind(originalSessionStorage), | ||
| setItem: vi.fn(() => { | ||
| throw new Error('QuotaExceededError') | ||
| }) | ||
| }), | ||
| removeItem: originalSessionStorage.removeItem.bind( | ||
| originalSessionStorage | ||
| ), | ||
| clear: originalSessionStorage.clear.bind(originalSessionStorage) | ||
| } satisfies Storage | ||
| vi.stubGlobal('sessionStorage', throwingSessionStorage) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking): this |
||
| const consoleWarnSpy = vi | ||
| .spyOn(console, 'warn') | ||
| .mockImplementation(() => {}) | ||
|
|
||
| const store = useWorkspaceAuthStore() | ||
| const { workspaceToken } = storeToRefs(store) | ||
|
|
||
| await store.switchWorkspace('workspace-123') | ||
| try { | ||
| const store = useWorkspaceAuthStore() | ||
| const { workspaceToken } = storeToRefs(store) | ||
|
|
||
| expect(workspaceToken.value).toBe('workspace-token-abc') | ||
| expect(consoleWarnSpy).toHaveBeenCalledWith( | ||
| 'Failed to persist workspace context to sessionStorage' | ||
| ) | ||
| await store.switchWorkspace('workspace-123') | ||
|
|
||
| setItemSpy.mockRestore() | ||
| consoleWarnSpy.mockRestore() | ||
| expect(workspaceToken.value).toBe('workspace-token-abc') | ||
| expect(consoleWarnSpy).toHaveBeenCalledWith( | ||
| 'Failed to persist workspace context to sessionStorage' | ||
| ) | ||
| } finally { | ||
| vi.stubGlobal('sessionStorage', originalSessionStorage) | ||
| consoleWarnSpy.mockRestore() | ||
| } | ||
| }) | ||
| }) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking): |
||
| const isLoading = ref(false) | ||
| const error = ref<Error | null>(null) | ||
|
|
||
|
|
@@ -82,6 +83,25 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| }, delay) | ||
| } | ||
|
|
||
| function scheduleTokenRefreshRetry(delayMs: number): void { | ||
| if (workspaceTokenExpiresAt.value === null) { | ||
| return | ||
| } | ||
|
|
||
| const timeUntilExpiry = workspaceTokenExpiresAt.value - Date.now() | ||
| if (timeUntilExpiry <= 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue: when Reachable after 4 fetch attempts plus 1+2+4s backoff (~7s) on a slow network when the token had little headroom. Could |
||
| return | ||
| } | ||
|
|
||
| stopRefreshTimer() | ||
| refreshTimerId = setTimeout( | ||
| () => { | ||
| void refreshToken() | ||
| }, | ||
| Math.min(delayMs, timeUntilExpiry) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): |
||
| ) | ||
| } | ||
|
|
||
| function persistToSession( | ||
| workspace: WorkspaceWithRole, | ||
| token: string, | ||
|
|
@@ -155,6 +175,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
|
|
||
| currentWorkspace.value = parseResult.data | ||
| workspaceToken.value = token | ||
| workspaceTokenExpiresAt.value = expiresAt | ||
| error.value = null | ||
|
|
||
| scheduleTokenRefresh(expiresAt) | ||
|
|
@@ -176,6 +197,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| if (currentWorkspace.value?.id !== workspaceId) { | ||
| refreshRequestId++ | ||
| } | ||
| const capturedRequestId = refreshRequestId | ||
|
|
||
| isLoading.value = true | ||
| error.value = null | ||
|
Comment on lines
202
to
203
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 306 always sets 💡 Proposed fix (track in-flight switches) // Timer state
let refreshTimerId: ReturnType<typeof setTimeout> | null = null
+ let inFlightSwitchCount = 0
@@
- isLoading.value = true
+ inFlightSwitchCount += 1
+ isLoading.value = true
error.value = null
@@
} finally {
- isLoading.value = false
+ inFlightSwitchCount = Math.max(0, inFlightSwitchCount - 1)
+ isLoading.value = inFlightSwitchCount > 0
}
}Also applies to: 305-307 🤖 Prompt for AI Agents |
||
|
|
@@ -257,12 +279,27 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| role: data.role | ||
| } | ||
|
|
||
| if (capturedRequestId !== refreshRequestId) { | ||
| console.warn( | ||
| 'Aborting stale workspace switch: workspace context changed before commit' | ||
| ) | ||
| return | ||
|
Comment on lines
+282
to
+286
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This guard drops any in-flight refresh response as soon as Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| currentWorkspace.value = workspaceWithRole | ||
| workspaceToken.value = data.token | ||
| workspaceTokenExpiresAt.value = expiresAt | ||
|
|
||
| persistToSession(workspaceWithRole, data.token, expiresAt) | ||
| scheduleTokenRefresh(expiresAt) | ||
| } catch (err) { | ||
| if (capturedRequestId !== refreshRequestId) { | ||
| console.warn( | ||
| 'Aborting stale workspace switch: workspace context changed before error commit' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking): would including |
||
| ) | ||
| return | ||
| } | ||
|
|
||
| error.value = err instanceof Error ? err : new Error(String(err)) | ||
| throw error.value | ||
| } finally { | ||
|
|
@@ -327,6 +364,16 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
|
|
||
| // Only clear context if this refresh is still for the current workspace | ||
| if (capturedRequestId === refreshRequestId) { | ||
| if (isTransientError && hasValidWorkspaceToken()) { | ||
| error.value = null | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): |
||
| scheduleTokenRefreshRetry(baseDelayMs * Math.pow(2, maxRetries)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (non-blocking): the preserve branch reschedules another retry on every failure, with no max-attempts ceiling across scheduled retries and no jitter. Against a backend returning 500 for the full token lifetime this can produce many hundreds of fetches and warn entries before the token finally expires. Would tracking |
||
| console.warn( | ||
| 'Failed to refresh workspace token after retries; preserving existing valid token:', | ||
| err | ||
| ) | ||
| return | ||
| } | ||
|
benceruleanlu marked this conversation as resolved.
|
||
|
|
||
| console.error('Failed to refresh workspace token after retries:', err) | ||
| clearWorkspaceContext() | ||
| } | ||
|
|
@@ -347,12 +394,21 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => { | |
| return workspaceToken.value ?? undefined | ||
| } | ||
|
|
||
| function hasValidWorkspaceToken(): boolean { | ||
| return ( | ||
| workspaceToken.value !== null && | ||
| workspaceTokenExpiresAt.value !== null && | ||
| workspaceTokenExpiresAt.value > Date.now() | ||
| ) | ||
| } | ||
|
|
||
| function clearWorkspaceContext(): void { | ||
| // Increment request ID to invalidate any in-flight stale refresh operations | ||
| refreshRequestId++ | ||
| stopRefreshTimer() | ||
| currentWorkspace.value = null | ||
| workspaceToken.value = null | ||
| workspaceTokenExpiresAt.value = null | ||
| error.value = null | ||
| clearSessionStorage() | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick (non-blocking): the 7999 / 1 split asserts the scheduled retry fires at exactly 8000ms (=
baseDelayMs * 2^maxRetries). Would a one-line comment naming the formula make a future failure here self-explanatory?