@@ -263,3 +263,158 @@ describe('AuthOIDCClient.handleRedirectCallback — return shape', () => {
263263 assert . strictEqual ( 'iss' in lastExchangeBody , false , 'iss should be omitted, not sent as undefined' ) ;
264264 } ) ;
265265} ) ;
266+
267+ describe ( 'AuthOIDCClient.handleRedirectCallback — idempotency under double invocation' , ( ) => {
268+ const STATE = 'state-dbl' ;
269+ const BARE_USER = { userId : 'iss:sub' , username : 'alice' , email : 'alice@example.invalid' , provider : 'google' } ;
270+ let originalFetch : typeof globalThis . fetch ;
271+
272+ beforeEach ( ( ) => {
273+ installBrowserGlobals ( `http://localhost:3000/spa-callback?code=auth-code-dbl&state=${ STATE } ` ) ;
274+ process . env . BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api' ;
275+ store . set ( '__blocks_oidc_pending' , JSON . stringify ( {
276+ provider : 'google' ,
277+ verifier : 'v' ,
278+ state : STATE ,
279+ nonce : 'n' ,
280+ callbackUrl : 'http://localhost:3000/spa-callback' ,
281+ appState : 'app-state' ,
282+ } ) ) ;
283+ originalFetch = globalThis . fetch ;
284+ } ) ;
285+
286+ afterEach ( ( ) => {
287+ globalThis . fetch = originalFetch ;
288+ delete process . env . BLOCKS_API_URL ;
289+ clearBrowserGlobals ( ) ;
290+ } ) ;
291+
292+ test ( 'concurrent double invocation shares one exchange and both resolve to the same user' , async ( ) => {
293+ // React StrictMode mounts → unmounts → mounts, firing the callback effect
294+ // twice synchronously. Count the exchange POSTs to prove the single-use
295+ // PKCE code is exchanged exactly once and neither caller is stranded.
296+ let exchangeCalls = 0 ;
297+ globalThis . fetch = ( async ( _url : any , init ?: any ) => {
298+ if ( init ?. method === 'POST' ) exchangeCalls ++ ;
299+ // Settle on a later tick so both calls are genuinely in flight together.
300+ await new Promise ( ( r ) => setTimeout ( r , 5 ) ) ;
301+ return { ok : true , json : async ( ) => ( { user : BARE_USER } ) } ;
302+ } ) as unknown as typeof globalThis . fetch ;
303+
304+ const client = makeClient ( ) ;
305+ let notifyCount = 0 ;
306+ client . onAuthStateChange ( ( ) => { notifyCount ++ ; } ) ;
307+ // onAuthStateChange fires synchronously on subscribe with the last-known
308+ // state; capture that baseline so the assertion below measures only the
309+ // callback-driven notify as a delta, independent of cross-test module state.
310+ const notifyBaseline = notifyCount ;
311+
312+ // Fire twice WITHOUT awaiting the first — the double-mount race.
313+ const [ r1 , r2 ] = await Promise . all ( [
314+ client . handleRedirectCallback ( ) ,
315+ client . handleRedirectCallback ( ) ,
316+ ] ) ;
317+
318+ assert . ok ( r1 , 'first call must resolve a user' ) ;
319+ assert . ok ( r2 , 'second (concurrent) call must resolve a user — not null/throw' ) ;
320+ assert . strictEqual ( r1 ! . userId , 'iss:sub' ) ;
321+ assert . strictEqual ( r2 ! . userId , 'iss:sub' ) ;
322+ assert . strictEqual ( exchangeCalls , 1 , 'single-use PKCE code must be exchanged exactly once' ) ;
323+ assert . strictEqual ( notifyCount - notifyBaseline , 1 , 'callback should notify subscribers exactly once' ) ;
324+ assert . strictEqual ( store . get ( '__blocks_oidc_pending' ) , undefined , 'pending entry should be consumed' ) ;
325+ } ) ;
326+
327+ test ( 'a sequential double invocation also shares the in-flight result' , async ( ) => {
328+ // Same race, expressed as two calls captured before awaiting either.
329+ let exchangeCalls = 0 ;
330+ globalThis . fetch = ( async ( _url : any , init ?: any ) => {
331+ if ( init ?. method === 'POST' ) exchangeCalls ++ ;
332+ await new Promise ( ( r ) => setTimeout ( r , 5 ) ) ;
333+ return { ok : true , json : async ( ) => ( { user : BARE_USER } ) } ;
334+ } ) as unknown as typeof globalThis . fetch ;
335+
336+ const client = makeClient ( ) ;
337+ const p1 = client . handleRedirectCallback ( ) ;
338+ const p2 = client . handleRedirectCallback ( ) ;
339+ const r1 = await p1 ;
340+ const r2 = await p2 ;
341+ assert . strictEqual ( r1 ! . userId , 'iss:sub' ) ;
342+ assert . strictEqual ( r2 ! . userId , 'iss:sub' ) ;
343+ assert . strictEqual ( exchangeCalls , 1 , 'only one exchange for the shared in-flight code' ) ;
344+ } ) ;
345+
346+ test ( 'releases the guard after settling so a fresh flow on the same page can run' , async ( ) => {
347+ let exchangeCalls = 0 ;
348+ globalThis . fetch = ( async ( _url : any , init ?: any ) => {
349+ if ( init ?. method === 'POST' ) exchangeCalls ++ ;
350+ return { ok : true , json : async ( ) => ( { user : BARE_USER } ) } ;
351+ } ) as unknown as typeof globalThis . fetch ;
352+
353+ const client = makeClient ( ) ;
354+ const first = await client . handleRedirectCallback ( ) ;
355+ assert . ok ( first , 'first flow resolves' ) ;
356+ assert . strictEqual ( exchangeCalls , 1 ) ;
357+
358+ // Simulate a brand-new flow (new code/state + freshly stored pending blob).
359+ installBrowserGlobals ( 'http://localhost:3000/spa-callback?code=auth-code-2&state=state-2' ) ;
360+ process . env . BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api' ;
361+ store . set ( '__blocks_oidc_pending' , JSON . stringify ( {
362+ provider : 'google' , verifier : 'v' , state : 'state-2' , nonce : 'n' ,
363+ callbackUrl : 'http://localhost:3000/spa-callback' ,
364+ } ) ) ;
365+
366+ const second = await client . handleRedirectCallback ( ) ;
367+ assert . ok ( second , 'second independent flow resolves — guard released after the first settled' ) ;
368+ assert . strictEqual ( exchangeCalls , 2 , 'the second flow runs its own exchange' ) ;
369+ } ) ;
370+
371+ test ( 'error path under concurrent double invocation rejects both callers identically and releases the guard' , async ( ) => {
372+ // The guard must propagate ONE shared rejection to both callers and
373+ // release on failure. Without this, a refactor that mishandled the shared
374+ // rejection (stranding the page) or failed to release the guard (blocking
375+ // a same-page retry) would keep the success-path tests green.
376+ let exchangeCalls = 0 ;
377+ globalThis . fetch = ( async ( _url : any , init ?: any ) => {
378+ if ( init ?. method === 'POST' ) exchangeCalls ++ ;
379+ // Settle on a later tick so both calls are genuinely in flight together.
380+ await new Promise ( ( r ) => setTimeout ( r , 5 ) ) ;
381+ return { ok : false , json : async ( ) => ( { error : 'invalid_grant' } ) } ;
382+ } ) as unknown as typeof globalThis . fetch ;
383+
384+ const client = makeClient ( ) ;
385+
386+ // Fire twice WITHOUT awaiting the first; allSettled captures both outcomes.
387+ const [ s1 , s2 ] = await Promise . allSettled ( [
388+ client . handleRedirectCallback ( ) ,
389+ client . handleRedirectCallback ( ) ,
390+ ] ) ;
391+
392+ assert . strictEqual ( s1 . status , 'rejected' , 'first call must reject when the exchange fails' ) ;
393+ assert . strictEqual ( s2 . status , 'rejected' , 'second (concurrent) call must reject too — never resolve null' ) ;
394+ // Both callers share the one in-flight promise, so the rejection is the
395+ // identical Error instance — not two independently-thrown errors.
396+ const reason1 = ( s1 as PromiseRejectedResult ) . reason ;
397+ const reason2 = ( s2 as PromiseRejectedResult ) . reason ;
398+ assert . strictEqual ( reason1 , reason2 , 'both callers must reject with the identical shared error' ) ;
399+ assert . match ( reason1 . message , / e x c h a n g e f a i l e d / i) ;
400+ assert . strictEqual ( exchangeCalls , 1 , 'single-use PKCE code must be exchanged exactly once, even on failure' ) ;
401+
402+ // The finally must release the guard on failure: a fresh-code flow on the
403+ // same page runs its own exchange instead of being blocked by a stale entry.
404+ installBrowserGlobals ( 'http://localhost:3000/spa-callback?code=auth-code-retry&state=state-retry' ) ;
405+ process . env . BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api' ;
406+ store . set ( '__blocks_oidc_pending' , JSON . stringify ( {
407+ provider : 'google' , verifier : 'v' , state : 'state-retry' , nonce : 'n' ,
408+ callbackUrl : 'http://localhost:3000/spa-callback' ,
409+ } ) ) ;
410+ globalThis . fetch = ( async ( _url : any , init ?: any ) => {
411+ if ( init ?. method === 'POST' ) exchangeCalls ++ ;
412+ return { ok : true , json : async ( ) => ( { user : BARE_USER } ) } ;
413+ } ) as unknown as typeof globalThis . fetch ;
414+
415+ const retry = await client . handleRedirectCallback ( ) ;
416+ assert . ok ( retry , 'a fresh-code flow resolves — the guard was released after the failure' ) ;
417+ assert . strictEqual ( retry ! . userId , 'iss:sub' ) ;
418+ assert . strictEqual ( exchangeCalls , 2 , 'the fresh flow runs its own second exchange' ) ;
419+ } ) ;
420+ } ) ;
0 commit comments