@@ -19,19 +19,62 @@ const CURRENT_PAGE = 'http://localhost:3000/dashboard';
1919const AUTHORIZE_URL = 'https://idp.example.com/authorize' ;
2020
2121let navigatedTo = '' ;
22+ let reloaded = false ;
2223let store : Map < string , string > ;
2324
25+ /**
26+ * Cross-tab payloads captured from `BroadcastChannel.postMessage` so tests can
27+ * assert the OIDC client bridged its sign-in into `@aws-blocks/auth-common`.
28+ * Cleared (in place — the stub closes over this exact array) on each install.
29+ */
30+ const broadcasts : unknown [ ] = [ ] ;
31+ let savedBroadcastChannel : unknown ;
32+
33+ /**
34+ * A no-op `BroadcastChannel`. `auth-common`'s `broadcastAuthChange()` lazily
35+ * opens a real channel via `getChannel()`; a real one is a ref'd libuv handle
36+ * that keeps `node --test` alive and hangs the run. This records posts instead.
37+ *
38+ * `auth-common` caches that channel in a module-level singleton it never resets,
39+ * so whichever test triggers the first `broadcastAuthChange()` pins the live
40+ * instance for the rest of the run. Every stub instance posts to the SAME
41+ * module-level `broadcasts` array (emptied per test in `installBrowserGlobals`),
42+ * so the captured posts stay correct regardless of which describe block ran
43+ * first — preserve that shared-array invariant if this stub is refactored.
44+ */
45+ class StubBroadcastChannel {
46+ name : string ;
47+ onmessage : ( ( ev : unknown ) => void ) | null = null ;
48+ constructor ( name : string ) { this . name = name ; }
49+ postMessage ( msg : unknown ) : void { broadcasts . push ( msg ) ; }
50+ addEventListener ( ) : void { }
51+ removeEventListener ( ) : void { }
52+ close ( ) : void { }
53+ }
54+
2455function installBrowserGlobals ( currentHref : string ) : void {
2556 const url = new URL ( currentHref ) ;
2657 const locationStub = {
2758 get href ( ) { return currentHref ; } ,
2859 set href ( v : string ) { navigatedTo = v ; } ,
2960 origin : url . origin ,
3061 pathname : url . pathname ,
62+ reload ( ) { reloaded = true ; } ,
3163 } ;
3264 store = new Map < string , string > ( ) ;
33-
34- ( globalThis as any ) . window = { location : locationStub } ;
65+ broadcasts . length = 0 ;
66+ reloaded = false ;
67+
68+ // Back `window` with a real EventTarget so auth-common's
69+ // `window.dispatchEvent(new CustomEvent('blocks-auth-change', …))` works and
70+ // tests can listen for the same-window auth-change event.
71+ const target = new EventTarget ( ) ;
72+ ( globalThis as any ) . window = {
73+ location : locationStub ,
74+ addEventListener : target . addEventListener . bind ( target ) ,
75+ removeEventListener : target . removeEventListener . bind ( target ) ,
76+ dispatchEvent : target . dispatchEvent . bind ( target ) ,
77+ } ;
3578 ( globalThis as any ) . sessionStorage = {
3679 getItem : ( k : string ) => store . get ( k ) ?? null ,
3780 setItem : ( k : string , v : string ) => { store . set ( k , v ) ; } ,
@@ -40,12 +83,19 @@ function installBrowserGlobals(currentHref: string): void {
4083 // The client builds `redirect_uri` against window.location.href; some
4184 // code paths also read the global `location`. Mirror it.
4285 ( globalThis as any ) . location = locationStub ;
86+
87+ // Swap in the no-op BroadcastChannel before any broadcastAuthChange() call
88+ // caches a (real) channel instance.
89+ savedBroadcastChannel = ( globalThis as any ) . BroadcastChannel ;
90+ ( globalThis as any ) . BroadcastChannel = StubBroadcastChannel ;
4391}
4492
4593function clearBrowserGlobals ( ) : void {
4694 delete ( globalThis as any ) . window ;
4795 delete ( globalThis as any ) . sessionStorage ;
4896 delete ( globalThis as any ) . location ;
97+ if ( savedBroadcastChannel === undefined ) delete ( globalThis as any ) . BroadcastChannel ;
98+ else ( globalThis as any ) . BroadcastChannel = savedBroadcastChannel ;
4999 navigatedTo = '' ;
50100}
51101
@@ -418,3 +468,188 @@ describe('AuthOIDCClient.handleRedirectCallback — idempotency under double inv
418468 assert . strictEqual ( exchangeCalls , 2 , 'the fresh flow runs its own second exchange' ) ;
419469 } ) ;
420470} ) ;
471+
472+ describe ( 'AuthOIDCClient.handleRedirectCallback — @aws-blocks/auth-common bridge' , ( ) => {
473+ const STATE = 'state-bridge' ;
474+ const BARE_USER = { userId : 'iss:sub' , username : 'alice' , email : 'alice@example.invalid' , provider : 'google' } ;
475+ let originalFetch : typeof globalThis . fetch ;
476+
477+ beforeEach ( ( ) => {
478+ installBrowserGlobals ( `http://localhost:3000/spa-callback?code=auth-code&state=${ STATE } ` ) ;
479+ process . env . BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api' ;
480+ store . set ( '__blocks_oidc_pending' , JSON . stringify ( {
481+ provider : 'google' ,
482+ verifier : 'v' ,
483+ state : STATE ,
484+ nonce : 'n' ,
485+ callbackUrl : 'http://localhost:3000/spa-callback' ,
486+ appState : 'app-state' ,
487+ } ) ) ;
488+ originalFetch = globalThis . fetch ;
489+ } ) ;
490+
491+ afterEach ( ( ) => {
492+ globalThis . fetch = originalFetch ;
493+ delete process . env . BLOCKS_API_URL ;
494+ clearBrowserGlobals ( ) ;
495+ } ) ;
496+
497+ function stubExchangeOk ( body : unknown ) : void {
498+ globalThis . fetch = ( async ( ) => ( { ok : true , json : async ( ) => body } ) ) as unknown as typeof globalThis . fetch ;
499+ }
500+
501+ test ( 'dispatches a same-window auth-change event so on-page onAuthChange consumers re-render' , async ( ) => {
502+ stubExchangeOk ( { user : BARE_USER } ) ;
503+ // auth-common's broadcastAuthChange() fires a 'blocks-auth-change'
504+ // CustomEvent on window; onAuthChange listeners on THIS page rely on it.
505+ let detail : any = null ;
506+ ( globalThis as any ) . window . addEventListener ( 'blocks-auth-change' , ( e : any ) => { detail = e . detail ; } ) ;
507+
508+ const client = makeClient ( ) ;
509+ const user = await client . handleRedirectCallback ( ) ;
510+
511+ assert . ok ( user , 'callback should resolve a user' ) ;
512+ assert . ok ( detail , 'a blocks-auth-change event should have been dispatched on window' ) ;
513+ assert . strictEqual ( detail . type , 'auth-change' ) ;
514+ assert . strictEqual ( detail . user . userId , 'iss:sub' ) ;
515+ assert . strictEqual ( detail . user . username , 'alice' ) ;
516+ } ) ;
517+
518+ test ( 'posts the signed-in user across tabs via BroadcastChannel' , async ( ) => {
519+ stubExchangeOk ( { user : BARE_USER } ) ;
520+ const client = makeClient ( ) ;
521+ await client . handleRedirectCallback ( ) ;
522+
523+ assert . strictEqual ( broadcasts . length , 1 , 'exactly one cross-tab post should have been made' ) ;
524+ const msg = broadcasts [ 0 ] as any ;
525+ assert . strictEqual ( msg . type , 'auth-change' ) ;
526+ assert . strictEqual ( msg . user . userId , 'iss:sub' ) ;
527+ } ) ;
528+
529+ test ( 'does NOT broadcast when the callback fails (state mismatch)' , async ( ) => {
530+ stubExchangeOk ( { user : BARE_USER } ) ;
531+ // Tamper the stored state so validation throws before any exchange.
532+ store . set ( '__blocks_oidc_pending' , JSON . stringify ( {
533+ provider : 'google' , verifier : 'v' , state : 'a-different-state' , nonce : 'n' ,
534+ callbackUrl : 'http://localhost:3000/spa-callback' ,
535+ } ) ) ;
536+ let dispatched = false ;
537+ ( globalThis as any ) . window . addEventListener ( 'blocks-auth-change' , ( ) => { dispatched = true ; } ) ;
538+
539+ const client = makeClient ( ) ;
540+ await assert . rejects ( ( ) => client . handleRedirectCallback ( ) , / s t a t e m i s m a t c h / ) ;
541+
542+ assert . strictEqual ( dispatched , false , 'no auth-change event on a failed callback' ) ;
543+ assert . strictEqual ( broadcasts . length , 0 , 'no cross-tab post on a failed callback' ) ;
544+ } ) ;
545+ } ) ;
546+
547+ describe ( 'AuthOIDCClient.signOut — @aws-blocks/auth-common bridge' , ( ) => {
548+ let originalFetch : typeof globalThis . fetch ;
549+
550+ beforeEach ( ( ) => {
551+ installBrowserGlobals ( 'http://localhost:3000/dashboard' ) ;
552+ process . env . BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api' ;
553+ originalFetch = globalThis . fetch ;
554+ // The /aws-blocks/auth/signout POST just needs to resolve OK.
555+ globalThis . fetch = ( async ( ) => ( { ok : true , json : async ( ) => ( { } ) } ) ) as unknown as typeof globalThis . fetch ;
556+ } ) ;
557+
558+ afterEach ( ( ) => {
559+ globalThis . fetch = originalFetch ;
560+ delete process . env . BLOCKS_API_URL ;
561+ clearBrowserGlobals ( ) ;
562+ } ) ;
563+
564+ test ( 'posts a signed-out (null) user across tabs via BroadcastChannel' , async ( ) => {
565+ const client = makeClient ( ) ;
566+ await client . signOut ( ) ;
567+
568+ assert . strictEqual ( broadcasts . length , 1 , 'exactly one cross-tab post should have been made' ) ;
569+ const msg = broadcasts [ 0 ] as any ;
570+ assert . strictEqual ( msg . type , 'auth-change' ) ;
571+ assert . strictEqual ( msg . user , null , 'sign-out broadcasts a null user so other tabs re-render' ) ;
572+ } ) ;
573+
574+ test ( 'dispatches a same-window auth-change(null) event before reloading' , async ( ) => {
575+ // Other tabs rely on the cross-tab post above; same-tab onAuthChange
576+ // consumers rely on this same-window event. The page then reloads.
577+ let detail : any = 'unset' ;
578+ ( globalThis as any ) . window . addEventListener ( 'blocks-auth-change' , ( e : any ) => { detail = e . detail ; } ) ;
579+
580+ const client = makeClient ( ) ;
581+ await client . signOut ( ) ;
582+
583+ assert . notStrictEqual ( detail , 'unset' , 'a blocks-auth-change event should have been dispatched on window' ) ;
584+ assert . strictEqual ( detail . type , 'auth-change' ) ;
585+ assert . strictEqual ( detail . user , null ) ;
586+ assert . strictEqual ( reloaded , true , 'signOut should reload the page after broadcasting' ) ;
587+ } ) ;
588+ } ) ;
589+
590+ describe ( 'AuthOIDCClient.signOut — server-side (no window / BroadcastChannel)' , ( ) => {
591+ let originalFetch : typeof globalThis . fetch ;
592+ let savedWindow : unknown ;
593+ let savedLocation : unknown ;
594+ let savedSessionStorage : unknown ;
595+ let savedBroadcastChannelGlobal : unknown ;
596+ let signoutPosted : boolean ;
597+
598+ beforeEach ( ( ) => {
599+ // Emulate SSR: strip the browser globals that broadcastAuthChange() (a
600+ // BroadcastChannel + window.dispatchEvent) and the reload depend on.
601+ // Snapshot first so a sibling describe that installed them isn't disturbed.
602+ // Note: Node ships a real global BroadcastChannel, so it must be removed
603+ // too — otherwise an un-guarded broadcast would open a live channel.
604+ savedWindow = ( globalThis as any ) . window ;
605+ savedLocation = ( globalThis as any ) . location ;
606+ savedSessionStorage = ( globalThis as any ) . sessionStorage ;
607+ savedBroadcastChannelGlobal = ( globalThis as any ) . BroadcastChannel ;
608+ delete ( globalThis as any ) . window ;
609+ delete ( globalThis as any ) . location ;
610+ delete ( globalThis as any ) . sessionStorage ;
611+ delete ( globalThis as any ) . BroadcastChannel ;
612+
613+ // Reset the shared capture state (installBrowserGlobals normally does this)
614+ // so the assertions below can't observe a sibling test's broadcast/reload.
615+ broadcasts . length = 0 ;
616+ reloaded = false ;
617+
618+ // _getBaseUrl() resolves from this without touching window.
619+ process . env . BLOCKS_API_URL = 'http://localhost:3000/aws-blocks/api' ;
620+ originalFetch = globalThis . fetch ;
621+ signoutPosted = false ;
622+ globalThis . fetch = ( async ( url : any , init ?: any ) => {
623+ if ( String ( url ) . endsWith ( '/aws-blocks/auth/signout' ) && init ?. method === 'POST' ) {
624+ signoutPosted = true ;
625+ }
626+ return { ok : true , json : async ( ) => ( { } ) } ;
627+ } ) as unknown as typeof globalThis . fetch ;
628+ } ) ;
629+
630+ afterEach ( ( ) => {
631+ globalThis . fetch = originalFetch ;
632+ delete process . env . BLOCKS_API_URL ;
633+ // Restore exactly what we snapshotted so sibling tests are unaffected.
634+ if ( savedWindow === undefined ) delete ( globalThis as any ) . window ;
635+ else ( globalThis as any ) . window = savedWindow ;
636+ if ( savedLocation === undefined ) delete ( globalThis as any ) . location ;
637+ else ( globalThis as any ) . location = savedLocation ;
638+ if ( savedSessionStorage === undefined ) delete ( globalThis as any ) . sessionStorage ;
639+ else ( globalThis as any ) . sessionStorage = savedSessionStorage ;
640+ if ( savedBroadcastChannelGlobal === undefined ) delete ( globalThis as any ) . BroadcastChannel ;
641+ else ( globalThis as any ) . BroadcastChannel = savedBroadcastChannelGlobal ;
642+ } ) ;
643+
644+ test ( 'completes the server-side sign-out without a window and never broadcasts' , async ( ) => {
645+ const client = makeClient ( ) ;
646+ // Pre-fix this rejected: broadcastAuthChange(null) ran before the window
647+ // guard, so getChannel()/window.dispatchEvent threw a ReferenceError after
648+ // the sign-out POST had already completed — stranding the returned promise.
649+ await assert . doesNotReject ( ( ) => client . signOut ( ) ) ;
650+
651+ assert . strictEqual ( signoutPosted , true , 'the server-side sign-out POST should still run' ) ;
652+ assert . strictEqual ( broadcasts . length , 0 , 'no cross-tab broadcast should be attempted with no window' ) ;
653+ assert . strictEqual ( reloaded , false , 'no page reload server-side' ) ;
654+ } ) ;
655+ } ) ;
0 commit comments