@@ -741,6 +741,244 @@ describe('ProtectSession deadlines', () => {
741741 } ) ;
742742} ) ;
743743
744+ describe ( 'ProtectSession late token' , ( ) => {
745+ const STORE_KEY = '__clerk_protect_st' ;
746+
747+ beforeEach ( ( ) => {
748+ vi . useFakeTimers ( ) ;
749+ } ) ;
750+
751+ afterEach ( ( ) => {
752+ vi . useRealTimers ( ) ;
753+ delete ( navigator as unknown as Record < string , unknown > ) . locks ;
754+ } ) ;
755+
756+ const timedOut = async ( ) => {
757+ const h = session ( [ loader ( { token_timeout_ms : 5_000 } ) ] ) ;
758+ h . session ?. start ( ) ;
759+ const first = h . session ?. getRequestParams ( ) ;
760+ await vi . advanceTimersByTimeAsync ( 5_000 ) ;
761+ await expect ( first ) . resolves . toMatchObject ( { __clerk_protect_status : 'timeout' } ) ;
762+ return h ;
763+ } ;
764+
765+ it ( 'keeps a token whose loader finishes after the deadline for the next sign-in' , async ( ) => {
766+ const { session : created , elements } = await timedOut ( ) ;
767+
768+ await vi . advanceTimersByTimeAsync ( 2_000 ) ;
769+ serveInline ( elements [ 0 ] , { cid : created ?. placeholders ( ) . cid } ) ;
770+ await vi . advanceTimersByTimeAsync ( 0 ) ;
771+
772+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( {
773+ __clerk_protect_token : 'v1.payload.mac' ,
774+ __clerk_protect_status : 'ok' ,
775+ } ) ;
776+ // Still inside the cooldown, so the token can only have come from the late load.
777+ expect ( elements ) . toHaveLength ( 1 ) ;
778+ } ) ;
779+
780+ it ( 'keeps a token whose ready settles after the deadline' , async ( ) => {
781+ const { session : created , elements } = session ( [ loader ( { token_timeout_ms : 5_000 } ) ] ) ;
782+ created ?. start ( ) ;
783+ const first = created ?. getRequestParams ( ) ;
784+
785+ let release : ( value : unknown ) => void = ( ) => undefined ;
786+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
787+ serveInline ( elements [ 0 ] , {
788+ cid : created ?. placeholders ( ) . cid ,
789+ ready : new Promise ( resolve => {
790+ release = resolve ;
791+ } ) ,
792+ } ) ;
793+ await vi . advanceTimersByTimeAsync ( 4_000 ) ;
794+ await expect ( first ) . resolves . toMatchObject ( { __clerk_protect_status : 'timeout' } ) ;
795+
796+ // Well past a second deadline window, so only an unbounded late read can still catch it.
797+ await vi . advanceTimersByTimeAsync ( 7_000 ) ;
798+ release ( { token : 'v1.late.mac' , exp : nowSeconds ( ) + 43_200 } ) ;
799+ await vi . advanceTimersByTimeAsync ( 0 ) ;
800+
801+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( {
802+ __clerk_protect_token : 'v1.late.mac' ,
803+ __clerk_protect_status : 'ok' ,
804+ } ) ;
805+ } ) ;
806+
807+ it ( 'does not replace a stored token that lasts longer' , async ( ) => {
808+ const { session : created , elements } = await timedOut ( ) ;
809+
810+ localStorage . setItem (
811+ STORE_KEY ,
812+ storedEntry ( { token : 'v1.leader.mac' , rid : 'c' . repeat ( 26 ) , exp : nowSeconds ( ) + 43_260 } ) ,
813+ ) ;
814+ serveInline ( elements [ 0 ] , { cid : created ?. placeholders ( ) . cid } ) ;
815+ await vi . advanceTimersByTimeAsync ( 0 ) ;
816+
817+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( { __clerk_protect_token : 'v1.leader.mac' } ) ;
818+ } ) ;
819+
820+ it ( 'replaces a stored token that is still usable but expires sooner' , async ( ) => {
821+ const { session : created , elements } = await timedOut ( ) ;
822+
823+ localStorage . setItem ( STORE_KEY , storedEntry ( { token : 'v1.old.mac' , rid : 'c' . repeat ( 26 ) , exp : nowSeconds ( ) + 45 } ) ) ;
824+ serveInline ( elements [ 0 ] , { cid : created ?. placeholders ( ) . cid } ) ;
825+ await vi . advanceTimersByTimeAsync ( 0 ) ;
826+
827+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( { __clerk_protect_token : 'v1.payload.mac' } ) ;
828+ } ) ;
829+
830+ it ( 'does not replace a stored token stamped with a newer invalidation floor' , async ( ) => {
831+ const floor = nowSeconds ( ) - 60 ;
832+ const created = session ( [ loader ( { token_timeout_ms : 5_000 } ) ] , floor ) ;
833+ created . session ?. start ( ) ;
834+ const first = created . session ?. getRequestParams ( ) ;
835+ await vi . advanceTimersByTimeAsync ( 5_000 ) ;
836+ await expect ( first ) . resolves . toMatchObject ( { __clerk_protect_status : 'timeout' } ) ;
837+
838+ localStorage . setItem (
839+ STORE_KEY ,
840+ storedEntry ( { token : 'v1.newer.mac' , rid : 'c' . repeat ( 26 ) , exp : nowSeconds ( ) + 3_600 , floor : floor + 30 } ) ,
841+ ) ;
842+ serveInline ( created . elements [ 0 ] , { cid : created . session ?. placeholders ( ) . cid } ) ;
843+ await vi . advanceTimersByTimeAsync ( 0 ) ;
844+
845+ await expect ( created . session ?. getRequestParams ( ) ) . resolves . toMatchObject ( { __clerk_protect_token : 'v1.newer.mac' } ) ;
846+ } ) ;
847+
848+ it ( 'waits for the cross-tab lock before storing a late token' , async ( ) => {
849+ let tail : Promise < unknown > = Promise . resolve ( ) ;
850+ Object . defineProperty ( navigator , 'locks' , {
851+ value : {
852+ request : vi . fn ( ( _key : string , _options : unknown , callback : ( ) => Promise < unknown > ) => {
853+ const result = tail . then ( ( ) => callback ( ) ) ;
854+ tail = result . catch ( ( ) => undefined ) ;
855+ return result ;
856+ } ) ,
857+ } ,
858+ configurable : true ,
859+ } ) ;
860+
861+ const a = await timedOut ( ) ;
862+
863+ // Another tab takes the lock and starts minting.
864+ const b = session ( [ loader ( { token_timeout_ms : 5_000 } ) ] ) ;
865+ b . session ?. start ( ) ;
866+ await vi . advanceTimersByTimeAsync ( 0 ) ;
867+ expect ( b . elements ) . toHaveLength ( 1 ) ;
868+
869+ serveInline ( a . elements [ 0 ] , { cid : a . session ?. placeholders ( ) . cid } ) ;
870+ await vi . advanceTimersByTimeAsync ( 0 ) ;
871+ expect ( localStorage . getItem ( STORE_KEY ) ) . toBeNull ( ) ;
872+
873+ serveInline ( b . elements [ 0 ] , {
874+ cid : b . session ?. placeholders ( ) . cid ,
875+ ready : Promise . resolve ( { token : 'v1.b.mac' , exp : nowSeconds ( ) + 43_260 } ) ,
876+ } ) ;
877+ await vi . advanceTimersByTimeAsync ( 0 ) ;
878+
879+ expect ( JSON . parse ( localStorage . getItem ( STORE_KEY ) as string ) ) . toMatchObject ( { token : 'v1.b.mac' } ) ;
880+ } ) ;
881+
882+ it . each ( [
883+ { name : 'an empty store' , stored : undefined , token : 'v1.payload.mac' } ,
884+ { name : 'a longer-lived stored token' , stored : { exp : 43_260 , floor : 0 } , token : 'v1.leader.mac' } ,
885+ { name : 'a newer invalidation floor' , stored : { exp : 3_600 , floor : 30 } , token : 'v1.leader.mac' } ,
886+ ] ) ( 'retries a timed-out lock and respects $name' , async ( { stored, token } ) => {
887+ let calls = 0 ;
888+ const request = vi . fn ( ( _key : string , options : { signal : AbortSignal } , callback : ( ) => Promise < unknown > ) => {
889+ if ( ++ calls === 2 ) {
890+ return new Promise ( ( _resolve , reject ) => {
891+ options . signal . addEventListener ( 'abort' , ( ) => reject ( new DOMException ( 'aborted' , 'AbortError' ) ) ) ;
892+ } ) ;
893+ }
894+ return callback ( ) ;
895+ } ) ;
896+ Object . defineProperty ( navigator , 'locks' , { value : { request } , configurable : true } ) ;
897+
898+ const { session : created , elements } = await timedOut ( ) ;
899+ await vi . advanceTimersByTimeAsync ( 2_000 ) ;
900+ serveInline ( elements [ 0 ] , { cid : created ?. placeholders ( ) . cid } ) ;
901+ await vi . advanceTimersByTimeAsync ( 4_999 ) ;
902+
903+ expect ( request ) . toHaveBeenCalledTimes ( 2 ) ;
904+ expect ( localStorage . getItem ( STORE_KEY ) ) . toBeNull ( ) ;
905+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( { __clerk_protect_status : 'timeout' } ) ;
906+
907+ if ( stored ) {
908+ localStorage . setItem (
909+ STORE_KEY ,
910+ storedEntry ( { token, rid : 'c' . repeat ( 26 ) , exp : nowSeconds ( ) + stored . exp , floor : stored . floor } ) ,
911+ ) ;
912+ }
913+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
914+
915+ expect ( request ) . toHaveBeenCalledTimes ( 3 ) ;
916+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( {
917+ __clerk_protect_token : token ,
918+ __clerk_protect_status : 'ok' ,
919+ } ) ;
920+ expect ( elements ) . toHaveLength ( 1 ) ;
921+ } ) ;
922+
923+ it ( 'stops retrying the lock when the late token is no longer usable' , async ( ) => {
924+ let calls = 0 ;
925+ const request = vi . fn ( ( _key : string , options : { signal : AbortSignal } , callback : ( ) => Promise < unknown > ) => {
926+ if ( ++ calls > 1 ) {
927+ return new Promise ( ( _resolve , reject ) => {
928+ options . signal . addEventListener ( 'abort' , ( ) => reject ( new DOMException ( 'aborted' , 'AbortError' ) ) ) ;
929+ } ) ;
930+ }
931+ return callback ( ) ;
932+ } ) ;
933+ Object . defineProperty ( navigator , 'locks' , { value : { request } , configurable : true } ) ;
934+
935+ const { session : created , elements } = await timedOut ( ) ;
936+ serveInline ( elements [ 0 ] , {
937+ cid : created ?. placeholders ( ) . cid ,
938+ ready : Promise . resolve ( { token : 'v1.late.mac' , exp : nowSeconds ( ) + 12 } ) ,
939+ } ) ;
940+ await vi . advanceTimersByTimeAsync ( 20_000 ) ;
941+
942+ expect ( request ) . toHaveBeenCalledTimes ( 3 ) ;
943+ expect ( localStorage . getItem ( STORE_KEY ) ) . toBeNull ( ) ;
944+ } ) ;
945+
946+ it ( 'revalidates a late token after waiting for the lock' , async ( ) => {
947+ let calls = 0 ;
948+ const request = vi . fn ( ( _key : string , _options : unknown , callback : ( ) => Promise < unknown > ) => {
949+ if ( ++ calls === 2 ) {
950+ return new Promise ( resolve => setTimeout ( resolve , 4_000 ) ) . then ( callback ) ;
951+ }
952+ return callback ( ) ;
953+ } ) ;
954+ Object . defineProperty ( navigator , 'locks' , { value : { request } , configurable : true } ) ;
955+
956+ const { session : created , elements } = await timedOut ( ) ;
957+ serveInline ( elements [ 0 ] , {
958+ cid : created ?. placeholders ( ) . cid ,
959+ ready : Promise . resolve ( { token : 'v1.late.mac' , exp : nowSeconds ( ) + 7 } ) ,
960+ } ) ;
961+ await vi . advanceTimersByTimeAsync ( 4_000 ) ;
962+
963+ expect ( request ) . toHaveBeenCalledTimes ( 2 ) ;
964+ expect ( localStorage . getItem ( STORE_KEY ) ) . toBeNull ( ) ;
965+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( { __clerk_protect_status : 'timeout' } ) ;
966+ } ) ;
967+
968+ it . each ( [
969+ [ 'minted for another page' , { cid : 'someone-else' } ] ,
970+ [ 'carrying no token' , { ready : Promise . resolve ( { status : 'no_token' } ) } ] ,
971+ ] ) ( 'ignores a late result %s' , async ( _name , overrides ) => {
972+ const { session : created , elements } = await timedOut ( ) ;
973+
974+ serveInline ( elements [ 0 ] , { cid : created ?. placeholders ( ) . cid , ...overrides } ) ;
975+ await vi . advanceTimersByTimeAsync ( 0 ) ;
976+
977+ await expect ( created ?. getRequestParams ( ) ) . resolves . toMatchObject ( { __clerk_protect_status : 'timeout' } ) ;
978+ expect ( localStorage . getItem ( STORE_KEY ) ) . toBeNull ( ) ;
979+ } ) ;
980+ } ) ;
981+
744982describe ( 'ProtectSession cross-tab single flight' , ( ) => {
745983 /**
746984 * A serialising Web Locks stand-in. jsdom has no `navigator.locks`, and the `browser-tabs-lock`
0 commit comments