1- import { beforeEach , describe , expect , test , vi } from 'vitest' ;
1+ import { afterEach , beforeEach , describe , expect , test , vi } from 'vitest' ;
22import crypto from 'crypto' ;
33import {
44 __clearDpopReplayCacheForTests ,
@@ -31,6 +31,10 @@ beforeEach(() => {
3131 __clearDpopReplayCacheForTests ( ) ;
3232} ) ;
3333
34+ afterEach ( ( ) => {
35+ vi . useRealTimers ( ) ;
36+ } ) ;
37+
3438describe ( 'verifyDpopProof' , ( ) => {
3539 test ( 'accepts a valid resource request proof for a DPoP-bound access token' , async ( ) => {
3640 const keyPair = await generateKeyPair ( ) ;
@@ -117,6 +121,79 @@ describe('verifyDpopProof', () => {
117121 error : 'invalid_dpop_proof' ,
118122 } ) ;
119123 } ) ;
124+
125+ test ( 'does not record a proof id before access-token hash validation passes' , async ( ) => {
126+ const keyPair = await generateKeyPair ( ) ;
127+ const request = new Request ( 'http://internal.test/api/ee/mcp' , { method : 'POST' } ) ;
128+ const proof = await signDpopProof ( {
129+ ...keyPair ,
130+ htm : 'POST' ,
131+ htu : 'https://sourcebot.test/api/mcp' ,
132+ accessToken : 'sboa_actual-token' ,
133+ jti : 'ath-mismatch-not-recorded' ,
134+ } ) ;
135+
136+ await expect ( verifyDpopProof ( {
137+ request,
138+ proof,
139+ expectedJkt : calculateDpopJkt ( keyPair . publicJwk ) ,
140+ accessToken : 'sboa_other-token' ,
141+ requireAccessTokenHash : true ,
142+ } ) ) . resolves . toMatchObject ( {
143+ ok : false ,
144+ error : 'invalid_dpop_proof' ,
145+ } ) ;
146+
147+ await expect ( verifyDpopProof ( {
148+ request,
149+ proof,
150+ expectedJkt : calculateDpopJkt ( keyPair . publicJwk ) ,
151+ accessToken : 'sboa_actual-token' ,
152+ requireAccessTokenHash : true ,
153+ } ) ) . resolves . toMatchObject ( { ok : true } ) ;
154+ } ) ;
155+
156+ test ( 'expires replay cache entries based on proof iat plus the accepted window' , async ( ) => {
157+ vi . useFakeTimers ( ) ;
158+ vi . setSystemTime ( new Date ( '2026-01-01T00:00:00.000Z' ) ) ;
159+
160+ const keyPair = await generateKeyPair ( ) ;
161+ const request = new Request ( 'http://internal.test/api/ee/oauth/token' , { method : 'POST' } ) ;
162+ const proof = await signDpopProof ( {
163+ ...keyPair ,
164+ htm : 'POST' ,
165+ htu : 'https://sourcebot.test/api/ee/oauth/token' ,
166+ iat : Math . floor ( Date . now ( ) / 1000 ) + 60 ,
167+ jti : 'future-iat-replay' ,
168+ } ) ;
169+
170+ await expect ( verifyDpopProof ( { request, proof } ) ) . resolves . toMatchObject ( { ok : true } ) ;
171+
172+ vi . setSystemTime ( new Date ( '2026-01-01T00:05:01.000Z' ) ) ;
173+
174+ await expect ( verifyDpopProof ( { request, proof } ) ) . resolves . toMatchObject ( {
175+ ok : false ,
176+ error : 'invalid_dpop_proof' ,
177+ errorDescription : 'DPoP proof jti has already been used.' ,
178+ } ) ;
179+ } ) ;
180+
181+ test . each ( [
182+ { header : null , payload : { } } ,
183+ { header : [ ] , payload : { } } ,
184+ { header : 'header' , payload : { } } ,
185+ { header : { } , payload : null } ,
186+ { header : { } , payload : [ ] } ,
187+ { header : { } , payload : 'payload' } ,
188+ ] ) ( 'rejects non-object JWT JSON values %#' , async ( { header, payload } ) => {
189+ const request = new Request ( 'http://internal.test/api/ee/oauth/token' , { method : 'POST' } ) ;
190+ const proof = `${ base64UrlJson ( header ) } .${ base64UrlJson ( payload ) } .signature` ;
191+
192+ await expect ( verifyDpopProof ( { request, proof } ) ) . resolves . toMatchObject ( {
193+ ok : false ,
194+ error : 'invalid_dpop_proof' ,
195+ } ) ;
196+ } ) ;
120197} ) ;
121198
122199async function generateKeyPair ( ) : Promise < KeyPair > {
@@ -144,11 +221,13 @@ async function signDpopProof({
144221 htm,
145222 htu,
146223 accessToken,
224+ iat = Math . floor ( Date . now ( ) / 1000 ) ,
147225 jti = crypto . randomUUID ( ) ,
148226} : KeyPair & {
149227 htm : string ;
150228 htu : string ;
151229 accessToken ?: string ;
230+ iat ?: number ;
152231 jti ?: string ;
153232} ) : Promise < string > {
154233 const encodedHeader = base64UrlJson ( {
@@ -159,7 +238,7 @@ async function signDpopProof({
159238 const encodedPayload = base64UrlJson ( {
160239 htm,
161240 htu,
162- iat : Math . floor ( Date . now ( ) / 1000 ) ,
241+ iat,
163242 jti,
164243 ...( accessToken ? { ath : getDpopAccessTokenHash ( accessToken ) } : { } ) ,
165244 } ) ;
0 commit comments