Skip to content

Commit 720f4c1

Browse files
feedback
1 parent 77d3864 commit 720f4c1

2 files changed

Lines changed: 103 additions & 12 deletions

File tree

packages/web/src/ee/features/oauth/dpop.test.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, test, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
22
import crypto from 'crypto';
33
import {
44
__clearDpopReplayCacheForTests,
@@ -31,6 +31,10 @@ beforeEach(() => {
3131
__clearDpopReplayCacheForTests();
3232
});
3333

34+
afterEach(() => {
35+
vi.useRealTimers();
36+
});
37+
3438
describe('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

122199
async 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
});

packages/web/src/ee/features/oauth/dpop.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,22 @@ export async function verifyDpopProof({
9999
return invalidDpopProof('DPoP proof must be a compact JWT.');
100100
}
101101

102-
let header: DpopHeader;
103-
let payload: DpopPayload;
102+
let parsedHeader: unknown;
103+
let parsedPayload: unknown;
104104
try {
105-
header = JSON.parse(base64UrlDecode(parts[0]).toString('utf8')) as DpopHeader;
106-
payload = JSON.parse(base64UrlDecode(parts[1]).toString('utf8')) as DpopPayload;
105+
parsedHeader = JSON.parse(base64UrlDecode(parts[0]).toString('utf8'));
106+
parsedPayload = JSON.parse(base64UrlDecode(parts[1]).toString('utf8'));
107107
} catch {
108108
return invalidDpopProof('DPoP proof header or payload is not valid JSON.');
109109
}
110110

111+
if (!isPlainObject(parsedHeader) || !isPlainObject(parsedPayload)) {
112+
return invalidDpopProof('DPoP proof header and payload must be JSON objects.');
113+
}
114+
115+
const header = parsedHeader as DpopHeader;
116+
const payload = parsedPayload as DpopPayload;
117+
111118
if (header.typ?.toLowerCase() !== 'dpop+jwt') {
112119
return invalidDpopProof('DPoP proof typ must be dpop+jwt.');
113120
}
@@ -160,10 +167,7 @@ export async function verifyDpopProof({
160167
if (typeof payload.jti !== 'string' || payload.jti.length === 0) {
161168
return invalidDpopProof('DPoP proof jti is required.');
162169
}
163-
164-
if (!recordProofJti(jkt, payload.jti)) {
165-
return invalidDpopProof('DPoP proof jti has already been used.');
166-
}
170+
const proofExpiresAt = (payload.iat + DPOP_PROOF_IAT_WINDOW_SECONDS) * 1000;
167171

168172
if (accessToken || requireAccessTokenHash) {
169173
if (typeof payload.ath !== 'string' || !accessToken) {
@@ -175,6 +179,10 @@ export async function verifyDpopProof({
175179
}
176180
}
177181

182+
if (!recordProofJti(jkt, payload.jti, proofExpiresAt)) {
183+
return invalidDpopProof('DPoP proof jti has already been used.');
184+
}
185+
178186
return { ok: true, jkt };
179187
}
180188

@@ -190,6 +198,10 @@ function invalidDpopProof(errorDescription: string): VerifyDpopProofResult {
190198
};
191199
}
192200

201+
function isPlainObject(value: unknown): value is Record<string, unknown> {
202+
return typeof value === 'object' && value !== null && !Array.isArray(value);
203+
}
204+
193205
function isSupportedPublicJwk(jwk: DpopJwk): jwk is Required<Pick<DpopJwk, 'kty' | 'crv' | 'x' | 'y'>> & DpopJwk {
194206
return (
195207
jwk.kty === 'EC' &&
@@ -238,7 +250,7 @@ function base64UrlDecode(value: string): Buffer {
238250
return Buffer.from(value, 'base64url');
239251
}
240252

241-
function recordProofJti(jkt: string, jti: string): boolean {
253+
function recordProofJti(jkt: string, jti: string, proofExpiresAt: number): boolean {
242254
const now = Date.now();
243255
for (const [cacheKey, expiresAt] of seenProofJtis.entries()) {
244256
if (expiresAt <= now) {
@@ -251,6 +263,6 @@ function recordProofJti(jkt: string, jti: string): boolean {
251263
return false;
252264
}
253265

254-
seenProofJtis.set(cacheKey, now + DPOP_PROOF_IAT_WINDOW_SECONDS * 1000);
266+
seenProofJtis.set(cacheKey, proofExpiresAt);
255267
return true;
256268
}

0 commit comments

Comments
 (0)