@@ -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.
2526const 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.
4547const 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.
4853let draining = false
54+ let retriggerRequested = false
4955
5056let authWatcherInstalled = false
5157
5258function 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+
206239async 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