11import type { Mock } from 'vitest' ;
22import { getWorkOS } from './workos.js' ;
33import { handleAuth } from './authkit-callback-route.js' ;
4+ import { getPKCECookieNameForState } from './pkce.js' ;
45import { getSessionFromCookie , saveSession } from './session.js' ;
56import { NextRequest , NextResponse } from 'next/server' ;
67import { sealData } from 'iron-session' ;
@@ -56,7 +57,7 @@ describe('authkit-callback-route', () => {
5657
5758 async function setAuthCookie ( req : NextRequest , state : State ) : Promise < string > {
5859 const sealedState = await sealData ( state , { password : process . env . WORKOS_COOKIE_PASSWORD ! } ) ;
59- req . cookies . set ( 'wos-auth-verifier' , sealedState ) ;
60+ req . cookies . set ( getPKCECookieNameForState ( sealedState ) , sealedState ) ;
6061 return sealedState ;
6162 }
6263
@@ -548,10 +549,11 @@ describe('authkit-callback-route', () => {
548549 it ( 'should return an error response when PKCE cookie is corrupted' , async ( ) => {
549550 vi . mocked ( workos . userManagement . authenticateWithCode ) . mockResolvedValue ( mockAuthResponse ) ;
550551
551- // Set a corrupted cookie
552- request . cookies . set ( 'wos-auth-verifier' , 'not-a-valid-sealed-value' ) ;
552+ // Set a corrupted cookie using the flow-specific name
553+ const corruptedState = 'not-a-valid-sealed-value' ;
554+ request . cookies . set ( getPKCECookieNameForState ( corruptedState ) , corruptedState ) ;
553555 request . nextUrl . searchParams . set ( 'code' , 'test-code' ) ;
554- request . nextUrl . searchParams . set ( 'state' , 'not-a-valid-sealed-value' ) ;
556+ request . nextUrl . searchParams . set ( 'state' , corruptedState ) ;
555557
556558 const handler = handleAuth ( ) ;
557559 const response = await handler ( request ) ;
@@ -570,11 +572,12 @@ describe('authkit-callback-route', () => {
570572 const handler = handleAuth ( ) ;
571573 const response = await handler ( request ) ;
572574
573- // The response should be a redirect (success) and have a Set-Cookie header to delete the PKCE cookie
575+ // The response should be a redirect (success) and have a Set-Cookie header to delete the flow-specific PKCE cookie
574576 expect ( response . status ) . toBe ( 307 ) ;
575577
578+ const flowCookieName = getPKCECookieNameForState ( sealedState ) ;
576579 const setCookieHeaders = response . headers . getSetCookie ( ) ;
577- const pkceDeletionCookie = setCookieHeaders . find ( ( c : string ) => c . startsWith ( 'wos-auth-verifier=' ) ) ;
580+ const pkceDeletionCookie = setCookieHeaders . find ( ( c : string ) => c . startsWith ( ` ${ flowCookieName } =` ) ) ;
578581 expect ( pkceDeletionCookie ) . toBeDefined ( ) ;
579582 expect ( pkceDeletionCookie ) . toContain ( 'Max-Age=0' ) ;
580583 } ) ;
@@ -590,11 +593,70 @@ describe('authkit-callback-route', () => {
590593 const response = await handler ( request ) ;
591594
592595 expect ( response . status ) . toBe ( 500 ) ;
596+ const flowCookieName = getPKCECookieNameForState ( sealedState ) ;
593597 const setCookieHeaders = response . headers . getSetCookie ( ) ;
594- const pkceDeletionCookie = setCookieHeaders . find ( ( c : string ) => c . startsWith ( 'wos-auth-verifier=' ) ) ;
598+ const pkceDeletionCookie = setCookieHeaders . find ( ( c : string ) => c . startsWith ( ` ${ flowCookieName } =` ) ) ;
595599 expect ( pkceDeletionCookie ) . toBeDefined ( ) ;
596600 expect ( pkceDeletionCookie ) . toContain ( 'Max-Age=0' ) ;
597601 } ) ;
602+
603+ it ( 'should isolate concurrent auth flows using per-flow cookie names' , async ( ) => {
604+ vi . mocked ( workos . userManagement . authenticateWithCode ) . mockResolvedValue ( mockAuthResponse ) ;
605+
606+ // Simulate two concurrent auth flows with different sealed states
607+ const sealedStateA = await sealData (
608+ { nonce : 'nonce-a' , codeVerifier : 'verifier-a' } ,
609+ { password : process . env . WORKOS_COOKIE_PASSWORD ! } ,
610+ ) ;
611+ const sealedStateB = await sealData (
612+ { nonce : 'nonce-b' , codeVerifier : 'verifier-b' } ,
613+ { password : process . env . WORKOS_COOKIE_PASSWORD ! } ,
614+ ) ;
615+
616+ // Both cookies exist on the request (set by different middleware redirects)
617+ request . cookies . set ( getPKCECookieNameForState ( sealedStateA ) , sealedStateA ) ;
618+ request . cookies . set ( getPKCECookieNameForState ( sealedStateB ) , sealedStateB ) ;
619+
620+ // Callback for flow A — should find its own cookie
621+ request . nextUrl . searchParams . set ( 'code' , 'code-a' ) ;
622+ request . nextUrl . searchParams . set ( 'state' , sealedStateA ) ;
623+
624+ const handler = handleAuth ( ) ;
625+ const response = await handler ( request ) ;
626+
627+ expect ( response . status ) . toBe ( 307 ) ;
628+ expect ( workos . userManagement . authenticateWithCode ) . toHaveBeenCalledWith (
629+ expect . objectContaining ( { codeVerifier : 'verifier-a' } ) ,
630+ ) ;
631+
632+ // Flow B's cookie should NOT have been deleted
633+ const setCookieHeaders = response . headers . getSetCookie ( ) ;
634+ const flowBCookieName = getPKCECookieNameForState ( sealedStateB ) ;
635+ const flowBDeletion = setCookieHeaders . find ( ( c : string ) => c . startsWith ( `${ flowBCookieName } =` ) ) ;
636+ expect ( flowBDeletion ) . toBeUndefined ( ) ;
637+ } ) ;
638+
639+ it ( 'should fall back to the legacy shared PKCE cookie for v3.0.x in-flight flows' , async ( ) => {
640+ vi . mocked ( workos . userManagement . authenticateWithCode ) . mockResolvedValue ( mockAuthResponse ) ;
641+
642+ const sealedState = await sealData (
643+ { nonce : 'legacy' , codeVerifier : 'legacy-verifier' } ,
644+ { password : process . env . WORKOS_COOKIE_PASSWORD ! } ,
645+ ) ;
646+
647+ // Simulate a user mid-OAuth on v3.0.x: only the legacy cookie name exists
648+ request . cookies . set ( 'wos-auth-verifier' , sealedState ) ;
649+ request . nextUrl . searchParams . set ( 'code' , 'test-code' ) ;
650+ request . nextUrl . searchParams . set ( 'state' , sealedState ) ;
651+
652+ const handler = handleAuth ( ) ;
653+ const response = await handler ( request ) ;
654+
655+ expect ( response . status ) . toBe ( 307 ) ;
656+ expect ( workos . userManagement . authenticateWithCode ) . toHaveBeenCalledWith (
657+ expect . objectContaining ( { codeVerifier : 'legacy-verifier' } ) ,
658+ ) ;
659+ } ) ;
598660 } ) ;
599661 } ) ;
600662} ) ;
0 commit comments