Skip to content

Commit 4047ab0

Browse files
committed
fix: replay triggers that race an open drain and refresh the token after a 401
A trigger arriving while a drain is in progress (e.g. the auth watcher firing while the approval dialog is open) was dropped, leaving a mismatch-parked code stashed until an unrelated navigation; it now replays as one more drain pass. A 401 retry also minted the same cached id token; it now forces a refresh.
1 parent 72b22e8 commit 4047ab0

2 files changed

Lines changed: 83 additions & 36 deletions

File tree

src/platform/cloud/onboarding/desktopLoginRedemption.test.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { createMemoryHistory, createRouter } from 'vue-router'
1010
* an auth watcher, so every test drives a real in-memory router:
1111
* - Redemption requires explicit user approval, asked once per code and
1212
* account: an account change after approval (mid-dialog or before a retry)
13-
* must never redeem on the stale approval
13+
* must never redeem on the stale approval; a trigger that races an open
14+
* drain is replayed after it, so the mid-dialog case re-prompts under the
15+
* new account without another navigation
16+
* - A 401 forces a token refresh on the retry (a stale cached token must not
17+
* be resent)
1418
* - A valid code with an authenticated user is redeemed exactly once
1519
* - A session appearing without any navigation redeems via the auth watcher
1620
* - Terminal backend responses (400/403/404/409/410) drop the code with an
@@ -43,7 +47,10 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
4347
}))
4448

4549
interface MockAuthStore {
46-
currentUser: { uid: string; getIdToken: () => Promise<string> } | null
50+
currentUser: {
51+
uid: string
52+
getIdToken: (forceRefresh?: boolean) => Promise<string>
53+
} | null
4754
getIdToken: () => Promise<string>
4855
}
4956

@@ -386,6 +393,26 @@ describe('installDesktopLoginRedemption', () => {
386393
expect(mockFetch).toHaveBeenCalledTimes(2)
387394
})
388395

396+
it('forces a token refresh on the retry after a 401', async () => {
397+
const { trigger, seedStash, stashedCode } = await setup()
398+
seedStash(VALID_CODE)
399+
mockFetch
400+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
401+
.mockResolvedValueOnce(okResponse())
402+
403+
await trigger()
404+
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
405+
406+
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
407+
408+
expect(mockFetch).toHaveBeenCalledTimes(2)
409+
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
410+
expect(stashedCode()).toBeUndefined()
411+
expect(mockToastAdd).toHaveBeenCalledWith(
412+
expect.objectContaining({ severity: 'success' })
413+
)
414+
})
415+
389416
it('passes a timeout signal and treats an aborted request as transient', async () => {
390417
const { trigger, seedStash, stashedCode } = await setup()
391418
seedStash(VALID_CODE)
@@ -572,8 +599,8 @@ describe('installDesktopLoginRedemption', () => {
572599
expect(stashedCode()).toBeUndefined()
573600
})
574601

575-
it('does not redeem when the session changes while the approval dialog is open', async () => {
576-
const { trigger, seedStash, stashedCode } = await setup()
602+
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
603+
const { seedStash, stashedCode, trigger } = await setup()
577604
seedStash(VALID_CODE)
578605
let approve!: (value: boolean) => void
579606
mockConfirm.mockReturnValueOnce(
@@ -586,8 +613,10 @@ describe('installDesktopLoginRedemption', () => {
586613
await trigger()
587614
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
588615

589-
// The session swaps to user-2 while user-1's dialog is still open; the
590-
// approval that resolves now is ambiguous and must not redeem.
616+
// The session swaps to user-2 while user-1's dialog is still open. The
617+
// auth-change trigger lands mid-drain and must be replayed, not dropped:
618+
// user-1's approval resolving now is stale and must not redeem, and the
619+
// replay re-prompts under user-2 without needing another navigation.
591620
const secondUser = {
592621
uid: 'user-2',
593622
getIdToken: vi.fn().mockResolvedValue('second-user-token')
@@ -597,14 +626,8 @@ describe('installDesktopLoginRedemption', () => {
597626
approve(true)
598627
await flushRedemption()
599628

600-
expect(mockFetch).not.toHaveBeenCalled()
601-
expect(stashedCode()).toBe(VALID_CODE)
602-
603-
// The next trigger re-prompts under user-2 and redeems with its token.
604-
await trigger()
605-
606-
expect(mockConfirm).toHaveBeenCalledTimes(2)
607-
expect(mockFetch).toHaveBeenCalledTimes(1)
629+
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
630+
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
608631
expect(mockFetch).toHaveBeenCalledWith(
609632
REDEEM_URL,
610633
expect.objectContaining({

src/platform/cloud/onboarding/desktopLoginRedemption.ts

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
2121

2222
// Rejected (400/403), unknown/expired (404/410), or redeemed by a different
2323
// user (409) means the desktop app must start a fresh sign-in, so the code is
24-
// dropped. 401 stays transient: the session may still be settling post-login.
24+
// dropped. 401 stays transient: the session may still be settling post-login,
25+
// and the retry mints a fresh token instead of resending the same one.
2526
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
2627

2728
// Allows one transient-failure retry without ever looping within a page load.
@@ -38,21 +39,31 @@ interface CodeRedemptionState {
3839
attempts: number
3940
approvedUserUid: string | null
4041
settled: boolean
42+
forceTokenRefresh: boolean
4143
}
4244

4345
// Keyed by code so a different code arriving later gets its own approval and
4446
// attempt budget, while retries of the same code reuse both.
4547
const codeStates = new Map<string, CodeRedemptionState>()
4648

47-
// Coalesces concurrent triggers (afterEach can burst) into one drain.
49+
// Coalesces concurrent triggers (afterEach can burst) into one drain. A
50+
// trigger arriving mid-drain — e.g. the auth watcher firing while the
51+
// approval dialog is open — is not dropped: it is replayed as one more pass
52+
// once the current drain finishes.
4853
let draining = false
54+
let retriggerRequested = false
4955

5056
let authWatcherInstalled = false
5157

5258
function getCodeState(code: string): CodeRedemptionState {
5359
const existing = codeStates.get(code)
5460
if (existing) return existing
55-
const fresh = { attempts: 0, approvedUserUid: null, settled: false }
61+
const fresh = {
62+
attempts: 0,
63+
approvedUserUid: null,
64+
settled: false,
65+
forceTokenRefresh: false
66+
}
5667
codeStates.set(code, fresh)
5768
return fresh
5869
}
@@ -137,7 +148,8 @@ async function redeemCode(code: string): Promise<void> {
137148
// The session can change while the dialog is open or between a transient
138149
// failure and its retry: approval binds the code to one account, so redeem
139150
// only while that exact account is still active. On mismatch the code stays
140-
// stashed and the next trigger re-prompts under the now-current account.
151+
// stashed; the auth-change trigger (replayed if it raced this drain)
152+
// re-prompts under the now-current account.
141153
const approvedUser = useAuthStore().currentUser
142154
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
143155

@@ -148,7 +160,7 @@ async function redeemCode(code: string): Promise<void> {
148160
// flow must not trigger.
149161
let idToken: string
150162
try {
151-
idToken = await approvedUser.getIdToken()
163+
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
152164
} catch {
153165
handleTransientFailure(code, state, 'could not get id token')
154166
return
@@ -200,31 +212,43 @@ async function redeemCode(code: string): Promise<void> {
200212
return
201213
}
202214

215+
// A 401 with a live session usually means the cached id token went stale;
216+
// mint a fresh one on the retry instead of resending the same token.
217+
if (response.status === 401) state.forceTokenRefresh = true
203218
handleTransientFailure(code, state, `status ${response.status}`)
204219
}
205220

221+
// A newer code can replace the stashed one while an older redemption is
222+
// mid-flight, so after each redemption process whatever the stash holds now.
223+
// A pass that leaves its own code stashed — a transient failure awaiting its
224+
// retry timer, or an account mismatch awaiting a re-prompt — ends the pass.
225+
async function drainStash(): Promise<void> {
226+
let processedCode: string | undefined
227+
for (;;) {
228+
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
229+
if (!code || code === processedCode) return
230+
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
231+
clearPreservedQuery(NAMESPACE)
232+
return
233+
}
234+
processedCode = code
235+
await redeemCode(code)
236+
}
237+
}
238+
206239
async function redeemPendingDesktopLoginCode(): Promise<void> {
207240
// Never rejects: the triggers fire-and-forget this and must not be derailed
208241
// by a redemption failure.
209-
if (draining) return
242+
if (draining) {
243+
retriggerRequested = true
244+
return
245+
}
210246
draining = true
211247
try {
212-
// A newer code can replace the stashed one while an older redemption is
213-
// mid-flight (its trigger lands here while draining and returns), so after
214-
// each pass process whatever the stash holds now. A pass that leaves its
215-
// own code stashed — a transient failure awaiting its retry timer, or an
216-
// account mismatch awaiting the next trigger — ends the drain.
217-
let processedCode: string | undefined
218-
for (;;) {
219-
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
220-
if (!code || code === processedCode) return
221-
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
222-
clearPreservedQuery(NAMESPACE)
223-
return
224-
}
225-
processedCode = code
226-
await redeemCode(code)
227-
}
248+
do {
249+
retriggerRequested = false
250+
await drainStash()
251+
} while (retriggerRequested)
228252
} catch (error) {
229253
console.error('[DesktopLoginRedemption] Redemption failed:', error)
230254
} finally {

0 commit comments

Comments
 (0)