@@ -162,3 +162,96 @@ describe("OAuth2Server client auth enforcement", () => {
162162 expect ( validation . error ?. error ) . toBe ( "invalid_client" ) ;
163163 } ) ;
164164} ) ;
165+
166+ describe ( "OAuth2Server token lifecycle hardening" , ( ) => {
167+ it ( "invalidates old access token after refresh rotation" , async ( ) => {
168+ const server = new OAuth2Server ( publicClientConfig ) as any ;
169+ const pkce = generatePKCE ( ) ;
170+
171+ server . authorizationCodes . set ( "rotation-code" , {
172+ clientId : "test-client" ,
173+ redirectUri : "http://localhost:3000/callback" ,
174+ scopes : [ "mcp:access" ] ,
175+ codeChallenge : pkce . codeChallenge ,
176+ issuedAt : Date . now ( ) ,
177+ expiresAt : Date . now ( ) + 60_000 ,
178+ } ) ;
179+
180+ const initial = await server . handleAuthorizationCodeGrant ( {
181+ grantType : "authorization_code" ,
182+ clientId : "test-client" ,
183+ code : "rotation-code" ,
184+ redirectUri : "http://localhost:3000/callback" ,
185+ codeVerifier : pkce . codeVerifier ,
186+ } ) ;
187+
188+ const oldTokenReq = {
189+ headers : {
190+ authorization : `Bearer ${ initial . accessToken } ` ,
191+ } ,
192+ } ;
193+ const oldTokenBeforeRefresh = await server . validateBearerToken ( oldTokenReq ) ;
194+ expect ( oldTokenBeforeRefresh ) . not . toBeNull ( ) ;
195+
196+ const rotated = await server . handleRefreshTokenGrant ( {
197+ grantType : "refresh_token" ,
198+ clientId : "test-client" ,
199+ refreshToken : initial . refreshToken ,
200+ } ) ;
201+
202+ expect ( rotated . accessToken ) . not . toBe ( initial . accessToken ) ;
203+
204+ const oldTokenAfterRefresh = await server . validateBearerToken ( oldTokenReq ) ;
205+ expect ( oldTokenAfterRefresh ) . toBeNull ( ) ;
206+
207+ const newTokenReq = {
208+ headers : {
209+ authorization : `Bearer ${ rotated . accessToken } ` ,
210+ } ,
211+ } ;
212+ const newTokenSession = await server . validateBearerToken ( newTokenReq ) ;
213+ expect ( newTokenSession ) . not . toBeNull ( ) ;
214+ } ) ;
215+
216+ it ( "rejects refresh token exchange after refresh token expiry" , async ( ) => {
217+ let now = Date . now ( ) ;
218+ const shortRefreshConfig : OAuthConfig = {
219+ ...publicClientConfig ,
220+ refreshTokenLifetime : 1 ,
221+ } ;
222+ const server = new OAuth2Server ( shortRefreshConfig , {
223+ now : ( ) => now ,
224+ cleanupIntervalMs : 3600_000 ,
225+ } ) as any ;
226+ const pkce = generatePKCE ( ) ;
227+
228+ server . authorizationCodes . set ( "expiry-code" , {
229+ clientId : "test-client" ,
230+ redirectUri : "http://localhost:3000/callback" ,
231+ scopes : [ "mcp:access" ] ,
232+ codeChallenge : pkce . codeChallenge ,
233+ issuedAt : now ,
234+ expiresAt : now + 60_000 ,
235+ } ) ;
236+
237+ const issued = await server . handleAuthorizationCodeGrant ( {
238+ grantType : "authorization_code" ,
239+ clientId : "test-client" ,
240+ code : "expiry-code" ,
241+ redirectUri : "http://localhost:3000/callback" ,
242+ codeVerifier : pkce . codeVerifier ,
243+ } ) ;
244+
245+ now += 1_100 ;
246+
247+ await expect (
248+ server . handleRefreshTokenGrant ( {
249+ grantType : "refresh_token" ,
250+ clientId : "test-client" ,
251+ refreshToken : issued . refreshToken ,
252+ } ) ,
253+ ) . rejects . toThrow ( "invalid_grant" ) ;
254+
255+ expect ( server . sessions . size ) . toBe ( 0 ) ;
256+ } ) ;
257+ } ) ;
0 commit comments