@@ -551,6 +551,204 @@ test("POST /v1/tokens/refresh", async (t) => {
551551 assert . match ( JSON . stringify ( body ) , / r e v o k e d / i) ;
552552 } ) ;
553553 } ) ;
554+
555+ await t . test ( "revokes the old refresh JTI after a successful refresh" , async ( ) => {
556+ const { app, identity } = await createHarness ( ) ;
557+ const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair ( identity ) ;
558+ await seedActiveTokens ( app , identity . id , [ accessClaims . jti , refreshClaims . jti ] ) ;
559+
560+ assert . deepEqual ( await listRevokedTokenIds ( app ) , [ ] ) ;
561+
562+ const response = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
563+ body : { refreshToken : pair . refreshToken } ,
564+ } ) ;
565+ await assertJsonResponse < TokenPair > ( response , 200 ) ;
566+
567+ const revoked = await listRevokedTokenIds ( app ) ;
568+ assert . ok (
569+ revoked . includes ( refreshClaims . jti ) ,
570+ `old refresh JTI ${ refreshClaims . jti } should be in the revocation list but got ${ JSON . stringify ( revoked ) } ` ,
571+ ) ;
572+ } ) ;
573+
574+ await t . test ( "detects refresh-token re-use and cascade-revokes the session" , async ( ) => {
575+ const { app, identity } = await createHarness ( ) ;
576+ const { pair, accessClaims, refreshClaims } = createLegacyPhase0TokenPair ( identity ) ;
577+ await seedActiveTokens ( app , identity . id , [ accessClaims . jti , refreshClaims . jti ] ) ;
578+
579+ const firstResponse = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
580+ body : { refreshToken : pair . refreshToken } ,
581+ } ) ;
582+ const firstBody = await assertJsonResponse < TokenPair > ( firstResponse , 200 ) ;
583+ const secondRefreshClaims = decodeJwtJsonSegment < RelayAuthTokenClaims > ( firstBody . refreshToken , 1 ) ;
584+
585+ // Replay the original refresh token (single-use violation).
586+ const replayResponse = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
587+ body : { refreshToken : pair . refreshToken } ,
588+ } ) ;
589+ await assertJsonResponse < ErrorBody > ( replayResponse , 401 , ( body ) => {
590+ assert . match ( JSON . stringify ( body ) , / r e v o k e d / i) ;
591+ } ) ;
592+
593+ // The newly issued refresh token should ALSO be unusable now because the
594+ // session was cascade-revoked.
595+ const followupResponse = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
596+ body : { refreshToken : firstBody . refreshToken } ,
597+ } ) ;
598+ await assertJsonResponse < ErrorBody > ( followupResponse , 401 ) ;
599+
600+ const revoked = await listRevokedTokenIds ( app ) ;
601+ assert . ok ( revoked . includes ( refreshClaims . jti ) , "original refresh JTI must be revoked" ) ;
602+ assert . ok (
603+ revoked . includes ( secondRefreshClaims . jti ) ,
604+ `second refresh JTI ${ secondRefreshClaims . jti } must be revoked after re-use detection (got ${ JSON . stringify ( revoked ) } )` ,
605+ ) ;
606+ } ) ;
607+
608+ await t . test ( "rejects a refresh token signed with the wrong issuer" , async ( ) => {
609+ const { app, identity } = await createHarness ( ) ;
610+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
611+ const sid = `sess_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
612+ const jti = `tok_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
613+ await seedActiveTokens ( app , identity . id , [ jti ] ) ;
614+
615+ const evilRefresh = signLegacyHs256Jwt ( {
616+ sub : identity . id ,
617+ org : identity . orgId ,
618+ wks : identity . workspaceId ,
619+ scopes : [ "relayauth:token:refresh" ] ,
620+ sponsorId : identity . sponsorId ,
621+ sponsorChain : [ ...identity . sponsorChain ] ,
622+ token_type : "refresh" ,
623+ iss : "https://evil.example" ,
624+ aud : [ "relayauth" ] ,
625+ exp : now + 3600 ,
626+ iat : now ,
627+ jti,
628+ sid,
629+ } ) ;
630+
631+ const response = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
632+ body : { refreshToken : evilRefresh } ,
633+ } ) ;
634+ await assertJsonResponse < ErrorBody > ( response , 401 ) ;
635+ } ) ;
636+
637+ await t . test ( "rejects a refresh token with a non-relayauth audience" , async ( ) => {
638+ const { app, identity } = await createHarness ( ) ;
639+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
640+ const sid = `sess_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
641+ const jti = `tok_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
642+ await seedActiveTokens ( app , identity . id , [ jti ] ) ;
643+
644+ const wrongAudRefresh = signLegacyHs256Jwt ( {
645+ sub : identity . id ,
646+ org : identity . orgId ,
647+ wks : identity . workspaceId ,
648+ scopes : [ "relayauth:token:refresh" ] ,
649+ sponsorId : identity . sponsorId ,
650+ sponsorChain : [ ...identity . sponsorChain ] ,
651+ token_type : "refresh" ,
652+ iss : "https://relayauth.dev" ,
653+ aud : [ "not-relayauth" ] ,
654+ exp : now + 3600 ,
655+ iat : now ,
656+ jti,
657+ sid,
658+ } ) ;
659+
660+ const response = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
661+ body : { refreshToken : wrongAudRefresh } ,
662+ } ) ;
663+ await assertJsonResponse < ErrorBody > ( response , 401 ) ;
664+ } ) ;
665+
666+ await t . test ( "rejects a refresh token whose exp is beyond clock-skew in the past" , async ( ) => {
667+ const { app, identity } = await createHarness ( ) ;
668+ const past = Math . floor ( Date . now ( ) / 1000 ) - 1000 ;
669+ const expiredRefresh = signLegacyHs256Jwt ( {
670+ sub : identity . id ,
671+ org : identity . orgId ,
672+ wks : identity . workspaceId ,
673+ scopes : [ "relayauth:token:refresh" ] ,
674+ sponsorId : identity . sponsorId ,
675+ sponsorChain : [ ...identity . sponsorChain ] ,
676+ token_type : "refresh" ,
677+ iss : "https://relayauth.dev" ,
678+ aud : [ "relayauth" ] ,
679+ exp : past + 60 , // exp 120s before "now"
680+ iat : past ,
681+ jti : `tok_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ,
682+ } ) ;
683+
684+ const response = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
685+ body : { refreshToken : expiredRefresh } ,
686+ } ) ;
687+ await assertJsonResponse < ErrorBody > ( response , 401 , ( body ) => {
688+ assert . match ( JSON . stringify ( body ) , / e x p i r e d | i n v a l i d / i) ;
689+ } ) ;
690+ } ) ;
691+
692+ await t . test ( "accepts a refresh token whose exp is within the 60s clock-skew allowance" , async ( ) => {
693+ const { app, identity } = await createHarness ( ) ;
694+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
695+ const jti = `tok_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
696+ const sid = `sess_${ crypto . randomUUID ( ) . replace ( / - / g, "" ) } ` ;
697+ await seedActiveTokens ( app , identity . id , [ jti ] ) ;
698+
699+ const skewedRefresh = signLegacyHs256Jwt ( {
700+ sub : identity . id ,
701+ org : identity . orgId ,
702+ wks : identity . workspaceId ,
703+ scopes : [ "relayauth:token:refresh" ] ,
704+ sponsorId : identity . sponsorId ,
705+ sponsorChain : [ ...identity . sponsorChain ] ,
706+ token_type : "refresh" ,
707+ iss : "https://relayauth.dev" ,
708+ aud : [ "relayauth" ] ,
709+ exp : now - 30 , // 30s past exp, should be accepted within skew
710+ iat : now - 120 ,
711+ jti,
712+ sid,
713+ } ) ;
714+
715+ const response = await requestRoute ( app , "POST" , "/v1/tokens/refresh" , {
716+ body : { refreshToken : skewedRefresh } ,
717+ } ) ;
718+ await assertJsonResponse < TokenPair > ( response , 200 ) ;
719+ } ) ;
720+ } ) ;
721+
722+ test ( "POST /v1/tokens enforces max sponsor-chain depth" , async ( t ) => {
723+ await t . test ( "rejects issuance when identity.sponsorChain exceeds 10" , async ( ) => {
724+ const deepChain = Array . from ( { length : 11 } , ( _ , index ) =>
725+ index === 10 ? "agent_deep_subject" : `user_ancestor_${ index } ` ,
726+ ) ;
727+ const deepIdentity = createStoredIdentity ( {
728+ id : "agent_deep_subject" ,
729+ name : "Deep Subject" ,
730+ orgId : "org_tokens_route" ,
731+ workspaceId : "ws_tokens_route" ,
732+ sponsorId : "user_ancestor_0" ,
733+ sponsorChain : deepChain ,
734+ scopes : [ "specialist:invoke" ] ,
735+ } ) ;
736+
737+ const { app, authHeaders } = await createHarness ( { identity : deepIdentity } ) ;
738+
739+ const response = await requestRoute ( app , "POST" , "/v1/tokens" , {
740+ body : {
741+ identityId : deepIdentity . id ,
742+ scopes : [ "specialist:invoke" ] ,
743+ audience : [ "specialist" ] ,
744+ } ,
745+ headers : authHeaders ,
746+ } ) ;
747+
748+ await assertJsonResponse < ErrorBody > ( response , 400 , ( body ) => {
749+ assert . match ( JSON . stringify ( body ) , / d e l e g a t i o n | d e p t h | c h a i n / i) ;
750+ } ) ;
751+ } ) ;
554752} ) ;
555753
556754test ( "POST /v1/tokens/revoke" , async ( t ) => {
0 commit comments