@@ -218,7 +218,6 @@ describe('OAuth message listener lifecycle', () => {
218218 close : vi . fn ( )
219219 } as unknown as Window )
220220 vi . mocked ( window . localStorage . setItem ) . mockClear ( )
221- vi . mocked ( window . localStorage . getItem ) . mockReturnValue ( 'csrf-token' )
222221 vi . mocked ( window . sessionStorage . setItem ) . mockClear ( )
223222 vi . mocked ( window . sessionStorage . getItem ) . mockReturnValue ( null )
224223 } )
@@ -283,16 +282,356 @@ describe('OAuth message listener lifecycle', () => {
283282 )
284283 } )
285284
286- it ( 'does not attach a listener when redirectUri is not postMessage ' , async ( ) => {
285+ it ( 'does not attach a listener when display is fullScreen ' , async ( ) => {
287286 const oauth = makeOAuth ( { basePath : 'https://api.example.com' } )
288- // When redirectUri is a real URL the code does window.location.href = …
289- // and never enters the postMessage branch — don't await the never-settling promise
290- oauth . loginAsync ( { redirectUri : 'https://myapp.example.com/callback' } )
287+ // When display is fullScreen the code does window.location.href = …
288+ // and never enters the popup branch
289+ oauth . loginAsync ( {
290+ redirectUri : 'https://myapp.example.com/callback' ,
291+ display : 'fullScreen'
292+ } )
291293 await Promise . resolve ( )
292294 expect ( window . addEventListener ) . not . toHaveBeenCalledWith (
293295 'message' ,
294296 expect . any ( Function ) ,
295297 false
296298 )
297299 } )
300+
301+ it ( 'attaches a message listener for popup even with a real redirectUri' , async ( ) => {
302+ const oauth = makeOAuth ( { basePath : 'https://api.example.com' } )
303+ oauth . loginAsync ( {
304+ redirectUri : 'https://myapp.example.com/callback' ,
305+ display : 'popup'
306+ } )
307+ await Promise . resolve ( )
308+ expect ( window . addEventListener ) . toHaveBeenCalledWith (
309+ 'message' ,
310+ expect . any ( Function ) ,
311+ false
312+ )
313+ } )
314+ } )
315+
316+ describe ( 'OAuth._exchangeCodeForTokens (via getRedirectResult)' , ( ) => {
317+ let tokenStore : OAuthTokenStore
318+
319+ const mockProfile = {
320+ userId : 1 ,
321+ email : 'test@example.com' ,
322+ name : 'Test User' ,
323+ handle : 'testuser' ,
324+ verified : false ,
325+ profilePicture : null ,
326+ apiKey : 'test-api-key' ,
327+ sub : 1 ,
328+ iat : '2026-01-01'
329+ }
330+
331+ beforeEach ( ( ) => {
332+ tokenStore = new OAuthTokenStore ( )
333+ } )
334+
335+ afterEach ( ( ) => {
336+ vi . restoreAllMocks ( )
337+ } )
338+
339+ function setLocationWithCode ( code : string , state : string ) {
340+ Object . defineProperty ( window , 'location' , {
341+ value : {
342+ href : `https://example.com/callback?code=${ code } &state=${ state } ` ,
343+ origin : 'https://example.com' ,
344+ search : `?code=${ code } &state=${ state } ` ,
345+ hash : ''
346+ } ,
347+ writable : true
348+ } )
349+ }
350+
351+ function resetLocation ( ) {
352+ Object . defineProperty ( window , 'location' , {
353+ value : { href : '' , origin : 'https://example.com' , search : '' , hash : '' } ,
354+ writable : true
355+ } )
356+ }
357+
358+ it ( 'exchanges code for tokens and returns LoginResult' , async ( ) => {
359+ vi . mocked ( window . sessionStorage . getItem ) . mockImplementation (
360+ ( key : string ) => {
361+ if ( key === 'audiusOauthState' ) return 'test-state'
362+ if ( key === 'audiusPkceCodeVerifier' ) return 'test-verifier'
363+ if ( key === 'audiusPkceRedirectUri' )
364+ return 'https://example.com/callback'
365+ return null
366+ }
367+ )
368+ setLocationWithCode ( 'auth-code-123' , 'test-state' )
369+ ; ( window as any ) . history = { replaceState : vi . fn ( ) }
370+
371+ const fetchMock = vi . fn ( )
372+ // First call: /oauth/token
373+ fetchMock . mockResolvedValueOnce (
374+ new Response (
375+ JSON . stringify ( {
376+ access_token : 'access-123' ,
377+ refresh_token : 'refresh-123'
378+ } ) ,
379+ { status : 200 }
380+ )
381+ )
382+ // Second call: /oauth/me
383+ fetchMock . mockResolvedValueOnce (
384+ new Response ( JSON . stringify ( mockProfile ) , { status : 200 } )
385+ )
386+ vi . stubGlobal ( 'fetch' , fetchMock )
387+
388+ const oauth = new OAuth ( {
389+ apiKey : 'test-api-key' ,
390+ basePath : 'https://api.example.com' ,
391+ tokenStore
392+ } )
393+
394+ expect ( oauth . hasRedirectResult ) . toBe ( true )
395+ const result = await oauth . getRedirectResult ( )
396+
397+ expect ( result ) . not . toBeNull ( )
398+ expect ( result ! . profile . handle ) . toBe ( 'testuser' )
399+ expect ( result ! . encodedJwt ) . toBe ( 'access-123' )
400+ expect ( tokenStore . accessToken ) . toBe ( 'access-123' )
401+ expect ( tokenStore . refreshToken ) . toBe ( 'refresh-123' )
402+
403+ // Verify correct POST body for token exchange
404+ expect ( fetchMock ) . toHaveBeenCalledWith (
405+ 'https://api.example.com/oauth/token' ,
406+ expect . objectContaining ( {
407+ method : 'POST' ,
408+ body : JSON . stringify ( {
409+ grant_type : 'authorization_code' ,
410+ code : 'auth-code-123' ,
411+ code_verifier : 'test-verifier' ,
412+ client_id : 'test-api-key' ,
413+ redirect_uri : 'https://example.com/callback'
414+ } )
415+ } )
416+ )
417+
418+ // Second call returns null (consumed)
419+ expect ( oauth . hasRedirectResult ) . toBe ( false )
420+ expect ( await oauth . getRedirectResult ( ) ) . toBeNull ( )
421+
422+ resetLocation ( )
423+ } )
424+
425+ it ( 'returns null when no code/state in URL' , async ( ) => {
426+ resetLocation ( )
427+ const oauth = makeOAuth ( {
428+ basePath : 'https://api.example.com' ,
429+ tokenStore
430+ } )
431+ expect ( oauth . hasRedirectResult ) . toBe ( false )
432+ expect ( await oauth . getRedirectResult ( ) ) . toBeNull ( )
433+ } )
434+
435+ it ( 'returns null when code verifier is missing from sessionStorage' , async ( ) => {
436+ vi . mocked ( window . sessionStorage . getItem ) . mockReturnValue ( null )
437+ setLocationWithCode ( 'auth-code-123' , 'test-state' )
438+ ; ( window as any ) . history = { replaceState : vi . fn ( ) }
439+
440+ const oauth = new OAuth ( {
441+ apiKey : 'test-api-key' ,
442+ basePath : 'https://api.example.com' ,
443+ tokenStore
444+ } )
445+ // URL has code+state, so hasRedirectResult is true before detection
446+ expect ( oauth . hasRedirectResult ) . toBe ( true )
447+ // But getRedirectResult returns null because verifier is missing
448+ expect ( await oauth . getRedirectResult ( ) ) . toBeNull ( )
449+ // After detection, hasRedirectResult reflects consumed state
450+ expect ( oauth . hasRedirectResult ) . toBe ( false )
451+
452+ resetLocation ( )
453+ } )
454+
455+ it ( 'does not exchange when state does not match' , async ( ) => {
456+ vi . mocked ( window . sessionStorage . getItem ) . mockImplementation (
457+ ( key : string ) => {
458+ if ( key === 'audiusPkceCodeVerifier' ) return 'test-verifier'
459+ return null
460+ }
461+ )
462+ setLocationWithCode ( 'auth-code-123' , 'wrong-state' )
463+ ; ( window as any ) . history = { replaceState : vi . fn ( ) }
464+
465+ const oauth = new OAuth ( {
466+ apiKey : 'test-api-key' ,
467+ basePath : 'https://api.example.com' ,
468+ tokenStore
469+ } )
470+ expect ( oauth . hasRedirectResult ) . toBe ( true )
471+ expect ( await oauth . getRedirectResult ( ) ) . toBeNull ( )
472+ expect ( oauth . hasRedirectResult ) . toBe ( false )
473+
474+ resetLocation ( )
475+ } )
476+
477+ it ( 'cleans up the URL after detecting redirect params' , async ( ) => {
478+ vi . mocked ( window . sessionStorage . getItem ) . mockImplementation (
479+ ( key : string ) => {
480+ if ( key === 'audiusOauthState' ) return 'test-state'
481+ if ( key === 'audiusPkceCodeVerifier' ) return 'test-verifier'
482+ return null
483+ }
484+ )
485+ setLocationWithCode ( 'auth-code-123' , 'test-state' )
486+ const replaceStateSpy = vi . fn ( )
487+ ; ( window as any ) . history = { replaceState : replaceStateSpy }
488+
489+ // Mock fetch so the exchange doesn't fail
490+ const fetchMock = vi . fn ( )
491+ fetchMock . mockResolvedValueOnce (
492+ new Response ( JSON . stringify ( { access_token : 'a' , refresh_token : 'r' } ) , {
493+ status : 200
494+ } )
495+ )
496+ fetchMock . mockResolvedValueOnce (
497+ new Response ( JSON . stringify ( { userId : 1 , handle : 'x' } ) , { status : 200 } )
498+ )
499+ vi . stubGlobal ( 'fetch' , fetchMock )
500+
501+ const oauth = new OAuth ( {
502+ apiKey : 'test-api-key' ,
503+ basePath : 'https://api.example.com' ,
504+ tokenStore
505+ } )
506+
507+ // URL cleanup doesn't happen until getRedirectResult triggers detection
508+ expect ( replaceStateSpy ) . not . toHaveBeenCalled ( )
509+ await oauth . getRedirectResult ( )
510+
511+ expect ( replaceStateSpy ) . toHaveBeenCalledTimes ( 1 )
512+ const cleanedUrl = replaceStateSpy . mock . calls [ 0 ] ?. [ 2 ]
513+ expect ( cleanedUrl ) . not . toContain ( 'code=' )
514+ expect ( cleanedUrl ) . not . toContain ( 'state=' )
515+
516+ resetLocation ( )
517+ } )
518+
519+ it ( 'cleans up sessionStorage keys on redirect detection' , async ( ) => {
520+ vi . mocked ( window . sessionStorage . getItem ) . mockImplementation (
521+ ( key : string ) => {
522+ if ( key === 'audiusOauthState' ) return 'test-state'
523+ if ( key === 'audiusPkceCodeVerifier' ) return 'test-verifier'
524+ if ( key === 'audiusPkceRedirectUri' )
525+ return 'https://example.com/callback'
526+ return null
527+ }
528+ )
529+ setLocationWithCode ( 'auth-code-123' , 'test-state' )
530+ ; ( window as any ) . history = { replaceState : vi . fn ( ) }
531+ const fetchMock = vi . fn ( )
532+ fetchMock . mockResolvedValueOnce (
533+ new Response ( JSON . stringify ( { access_token : 'a' , refresh_token : 'r' } ) , {
534+ status : 200
535+ } )
536+ )
537+ fetchMock . mockResolvedValueOnce (
538+ new Response ( JSON . stringify ( { userId : 1 , handle : 'x' } ) , { status : 200 } )
539+ )
540+ vi . stubGlobal ( 'fetch' , fetchMock )
541+
542+ const oauth = new OAuth ( {
543+ apiKey : 'test-api-key' ,
544+ basePath : 'https://api.example.com' ,
545+ tokenStore
546+ } )
547+
548+ // Trigger detection
549+ await oauth . getRedirectResult ( )
550+
551+ expect ( window . sessionStorage . removeItem ) . toHaveBeenCalledWith (
552+ 'audiusPkceCodeVerifier'
553+ )
554+ expect ( window . sessionStorage . removeItem ) . toHaveBeenCalledWith (
555+ 'audiusPkceRedirectUri'
556+ )
557+
558+ resetLocation ( )
559+ } )
560+
561+ it ( 'detects code in URL fragment (responseMode=fragment)' , async ( ) => {
562+ vi . mocked ( window . sessionStorage . getItem ) . mockImplementation (
563+ ( key : string ) => {
564+ if ( key === 'audiusOauthState' ) return 'test-state'
565+ if ( key === 'audiusPkceCodeVerifier' ) return 'test-verifier'
566+ return null
567+ }
568+ )
569+ Object . defineProperty ( window , 'location' , {
570+ value : {
571+ href : 'https://example.com/callback#code=frag-code&state=test-state' ,
572+ origin : 'https://example.com' ,
573+ search : '' ,
574+ hash : '#code=frag-code&state=test-state'
575+ } ,
576+ writable : true
577+ } )
578+ ; ( window as any ) . history = { replaceState : vi . fn ( ) }
579+
580+ const fetchMock = vi . fn ( )
581+ fetchMock . mockResolvedValueOnce (
582+ new Response (
583+ JSON . stringify ( {
584+ access_token : 'access-frag' ,
585+ refresh_token : 'refresh-frag'
586+ } ) ,
587+ { status : 200 }
588+ )
589+ )
590+ fetchMock . mockResolvedValueOnce (
591+ new Response ( JSON . stringify ( mockProfile ) , { status : 200 } )
592+ )
593+ vi . stubGlobal ( 'fetch' , fetchMock )
594+
595+ const oauth = new OAuth ( {
596+ apiKey : 'test-api-key' ,
597+ basePath : 'https://api.example.com' ,
598+ tokenStore
599+ } )
600+
601+ expect ( oauth . hasRedirectResult ) . toBe ( true )
602+ const result = await oauth . getRedirectResult ( )
603+ expect ( result ) . not . toBeNull ( )
604+ expect ( result ! . encodedJwt ) . toBe ( 'access-frag' )
605+
606+ resetLocation ( )
607+ } )
608+
609+ it ( 'forwards code+state to opener via postMessage when in a popup' , async ( ) => {
610+ setLocationWithCode ( 'popup-code' , 'test-state' )
611+ const postMessageSpy = vi . fn ( )
612+ const closeSpy = vi . fn ( )
613+ ; ( window as any ) . opener = { postMessage : postMessageSpy }
614+ ; ( window as any ) . close = closeSpy
615+ ; ( window as any ) . history = { replaceState : vi . fn ( ) }
616+
617+ const oauth = new OAuth ( {
618+ apiKey : 'test-api-key' ,
619+ basePath : 'https://api.example.com' ,
620+ tokenStore
621+ } )
622+
623+ // Nothing happens until getRedirectResult is called
624+ expect ( postMessageSpy ) . not . toHaveBeenCalled ( )
625+ await oauth . getRedirectResult ( )
626+
627+ expect ( postMessageSpy ) . toHaveBeenCalledWith (
628+ { code : 'popup-code' , state : 'test-state' } ,
629+ 'https://example.com'
630+ )
631+ expect ( closeSpy ) . toHaveBeenCalled ( )
632+ // Should NOT start a local exchange
633+ expect ( oauth . hasRedirectResult ) . toBe ( false )
634+ ; ( window as any ) . opener = null
635+ resetLocation ( )
636+ } )
298637} )
0 commit comments