@@ -485,5 +485,192 @@ describe('OAuth Login Flow', () => {
485485 cy . log ( '[STEP 5] Redirect loop prevention test passed - token persisted and no redirect occurred' ) ;
486486 } ) ;
487487 } ) ;
488+
489+ describe ( 'Old Token Race Condition' , ( ) => {
490+ it ( 'should clear old expired token before processing OAuth callback' , ( ) => {
491+ const oldExpiredToken = 'old-expired-token-' + uuidv4 ( ) ;
492+ const oldRefreshToken = 'old-expired-refresh-' + uuidv4 ( ) ;
493+ const newAccessToken = 'new-oauth-token-' + uuidv4 ( ) ;
494+ const newRefreshToken = 'new-oauth-refresh-' + uuidv4 ( ) ;
495+ const mockUserId = uuidv4 ( ) ;
496+ const mockWorkspaceId = uuidv4 ( ) ;
497+
498+ cy . log ( '[TEST START] Testing old expired token race condition' ) ;
499+
500+ cy . log ( '[SETUP] Pre-populate localStorage with expired token' ) ;
501+ cy . window ( ) . then ( ( win ) => {
502+ // Set old expired token (expired 1 hour ago)
503+ win . localStorage . setItem ( 'token' , JSON . stringify ( {
504+ access_token : oldExpiredToken ,
505+ refresh_token : oldRefreshToken ,
506+ expires_at : Math . floor ( Date . now ( ) / 1000 ) - 3600 , // Expired!
507+ user : {
508+ id : mockUserId ,
509+ email : 'old@example.com' ,
510+ } ,
511+ } ) ) ;
512+ } ) ;
513+
514+ // Mock refresh endpoint to FAIL for old token, SUCCESS for new token
515+ cy . intercept ( 'POST' , `${ gotrueUrl } /token?grant_type=refresh_token` , ( req ) => {
516+ const { body } = req ;
517+
518+ if ( body . refresh_token === oldRefreshToken ) {
519+ // Old token refresh should fail
520+ req . reply ( {
521+ statusCode : 400 ,
522+ body : {
523+ error : 'invalid_grant' ,
524+ error_description : 'Refresh token is invalid or expired' ,
525+ } ,
526+ } ) ;
527+ } else if ( body . refresh_token === newRefreshToken ) {
528+ // New token refresh should succeed
529+ req . reply ( {
530+ statusCode : 200 ,
531+ body : {
532+ access_token : newAccessToken ,
533+ refresh_token : newRefreshToken ,
534+ expires_at : Math . floor ( Date . now ( ) / 1000 ) + 3600 ,
535+ user : {
536+ id : mockUserId ,
537+ email : 'test@example.com' ,
538+ email_confirmed_at : new Date ( ) . toISOString ( ) ,
539+ created_at : new Date ( ) . toISOString ( ) ,
540+ updated_at : new Date ( ) . toISOString ( ) ,
541+ } ,
542+ } ,
543+ } ) ;
544+ } else {
545+ // Unknown token
546+ req . reply ( { statusCode : 400 , body : { error : 'unknown_token' } } ) ;
547+ }
548+ } ) . as ( 'refreshToken' ) ;
549+
550+ // Mock verify for NEW token only
551+ cy . intercept ( 'GET' , `${ apiUrl } /api/user/verify/${ newAccessToken } ` , {
552+ statusCode : 200 ,
553+ body : {
554+ code : 0 ,
555+ data : { is_new : false } ,
556+ message : 'Success' ,
557+ } ,
558+ } ) . as ( 'verifyNewToken' ) ;
559+
560+ // Mock verify for OLD token (should NOT be called if fix is working)
561+ cy . intercept ( 'GET' , `${ apiUrl } /api/user/verify/${ oldExpiredToken } ` , {
562+ statusCode : 401 ,
563+ body : {
564+ code : 401 ,
565+ message : 'Token expired' ,
566+ } ,
567+ } ) . as ( 'verifyOldToken' ) ;
568+
569+ // Mock workspace endpoints
570+ cy . intercept ( 'GET' , `${ apiUrl } /api/user/workspace` , {
571+ statusCode : 200 ,
572+ body : {
573+ code : 0 ,
574+ data : {
575+ user_profile : { uuid : mockUserId } ,
576+ visiting_workspace : {
577+ workspace_id : mockWorkspaceId ,
578+ workspace_name : 'My Workspace' ,
579+ icon : '' ,
580+ created_at : Date . now ( ) . toString ( ) ,
581+ database_storage_id : '' ,
582+ owner_uid : 1 ,
583+ owner_name : 'Test User' ,
584+ member_count : 1 ,
585+ } ,
586+ workspaces : [
587+ {
588+ workspace_id : mockWorkspaceId ,
589+ workspace_name : 'My Workspace' ,
590+ icon : '' ,
591+ created_at : Date . now ( ) . toString ( ) ,
592+ database_storage_id : '' ,
593+ owner_uid : 1 ,
594+ owner_name : 'Test User' ,
595+ member_count : 1 ,
596+ } ,
597+ ] ,
598+ } ,
599+ message : 'Success' ,
600+ } ,
601+ } ) . as ( 'getUserWorkspaceInfo' ) ;
602+
603+ cy . intercept ( 'GET' , `${ apiUrl } /api/user/profile*` , {
604+ statusCode : 200 ,
605+ body : {
606+ code : 0 ,
607+ data : {
608+ uid : 1 ,
609+ uuid : mockUserId ,
610+ email : 'test@example.com' ,
611+ name : 'Test User' ,
612+ metadata : { } ,
613+ encryption_sign : null ,
614+ latest_workspace_id : mockWorkspaceId ,
615+ updated_at : Date . now ( ) ,
616+ } ,
617+ message : 'Success' ,
618+ } ,
619+ } ) . as ( 'getCurrentUser' ) ;
620+
621+ // Step 1: Simulate OAuth callback with NEW tokens
622+ cy . log ( '[STEP 1] Simulating OAuth callback with NEW tokens (old expired token in localStorage)' ) ;
623+ const callbackUrl = `${ baseUrl } /auth/callback#access_token=${ newAccessToken } &refresh_token=${ newRefreshToken } &expires_at=${ Math . floor ( Date . now ( ) / 1000 ) + 3600
624+ } &token_type=bearer`;
625+
626+ cy . visit ( callbackUrl , { failOnStatusCode : false } ) ;
627+ cy . wait ( 2000 ) ;
628+
629+ // Step 2: Verify NEW token was used for verification (not old token)
630+ cy . log ( '[STEP 2] Verifying NEW token is used for verification' ) ;
631+ cy . wait ( '@verifyNewToken' ) . then ( ( interception ) => {
632+ expect ( interception . response ?. statusCode ) . to . equal ( 200 ) ;
633+ cy . log ( '[SUCCESS] verifyToken called with NEW token (old token was cleared first)' ) ;
634+ } ) ;
635+
636+ // Step 3: Verify refresh was called with NEW token (not old expired token)
637+ cy . log ( '[STEP 3] Verifying refresh called with NEW token' ) ;
638+ cy . wait ( '@refreshToken' ) . then ( ( interception ) => {
639+ const requestBody = interception . request . body ;
640+
641+ expect ( requestBody . refresh_token ) . to . equal ( newRefreshToken ) ;
642+ cy . log ( '[SUCCESS] refreshToken called with NEW token (not old expired token)' ) ;
643+ } ) ;
644+
645+ // Step 4: Verify we're redirected to /app (not /login due to token invalidation)
646+ cy . log ( '[STEP 4] Verifying successful redirect to /app' ) ;
647+ cy . url ( { timeout : 15000 } ) . should ( 'include' , '/app' ) ;
648+ cy . url ( ) . should ( 'not.include' , '/login' ) ;
649+
650+ // Step 5: Verify NEW token is saved (old token replaced)
651+ cy . log ( '[STEP 5] Verifying NEW token is saved in localStorage' ) ;
652+ cy . window ( ) . then ( ( win ) => {
653+ const token = win . localStorage . getItem ( 'token' ) ;
654+
655+ expect ( token ) . to . exist ;
656+ const tokenData = JSON . parse ( token || '{}' ) ;
657+
658+ expect ( tokenData . access_token ) . to . equal ( newAccessToken ) ;
659+ expect ( tokenData . refresh_token ) . to . equal ( newRefreshToken ) ;
660+ // Old token should be completely replaced
661+ expect ( tokenData . access_token ) . to . not . equal ( oldExpiredToken ) ;
662+ expect ( tokenData . refresh_token ) . to . not . equal ( oldRefreshToken ) ;
663+ cy . log ( '[SUCCESS] NEW token saved, old token replaced' ) ;
664+ } ) ;
665+
666+ // Step 6: Wait to ensure no redirect loop occurs
667+ cy . log ( '[STEP 6] Verifying no redirect loop (session not invalidated)' ) ;
668+ cy . wait ( 3000 ) ;
669+ cy . url ( ) . should ( 'include' , '/app' ) ;
670+ cy . url ( ) . should ( 'not.include' , '/login' ) ;
671+
672+ cy . log ( '[TEST COMPLETE] Old token race condition handled correctly - old token cleared before OAuth processing' ) ;
673+ } ) ;
674+ } ) ;
488675} ) ;
489676
0 commit comments