Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions examples/clients/typescript/auth-test-dpop-no-jkt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node

import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';

/**
* DPoP client that omits `dpop_jkt` on the authorization request. Isolates a
* WARNING of sep-1932-client-dpop-jkt (SEP-1932 / RFC 9449 §10).
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true,
sendDpopJkt: false
});
}

runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-jkt <server-url>');
23 changes: 23 additions & 0 deletions examples/clients/typescript/auth-test-dpop-wrong-jkt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env node

import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';

/**
* Broken DPoP client: sends a `dpop_jkt` that does not match the DPoP proof
* key used at the token endpoint. Isolates a FAILURE of
* sep-1932-client-dpop-jkt; the test AS rejects the token request with 400
* `invalid_grant` (RFC 9449 §10).
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true,
wrongDpopJkt: true
});
}

runAsCli(runClient, import.meta.url, 'auth-test-dpop-wrong-jkt <server-url>');
9 changes: 6 additions & 3 deletions examples/clients/typescript/auth-test-dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import { runDpopClient } from './helpers/dpopClientFlow';
import { runAsCli } from './helpers/cliRunner';

/**
* Well-behaved DPoP client (SEP-1932 / RFC 9449): presents the DPoP-bound token
* with the `DPoP` Authorization scheme and a fresh proof on every MCP request.
* Well-behaved DPoP client (SEP-1932 / RFC 9449): binds the authorization code
* to its DPoP key via `dpop_jkt` (RFC 9449 §10), then presents the DPoP-bound
* token with the `DPoP` Authorization scheme and a fresh proof on every MCP
* request.
*/
export async function runClient(serverUrl: string): Promise<void> {
await runDpopClient(serverUrl, {
scheme: 'DPoP',
freshProofPerRequest: true,
sendTokenRequestProof: true,
handleAsNonce: true,
handleRsNonce: true
handleRsNonce: true,
sendDpopJkt: true
});
}

Expand Down
24 changes: 22 additions & 2 deletions examples/clients/typescript/helpers/dpopClientFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import { logger } from './logger';
* challenge (RFC 9449 §8); fails sep-1932-client-as-nonce
* - `handleRsNonce:false` → ignores the MCP server's `use_dpop_nonce`
* challenge (RFC 9449 §9); fails sep-1932-client-rs-nonce
* - `sendDpopJkt:false` → omits dpop_jkt on the authorization request;
* warns sep-1932-client-dpop-jkt (SEP-1932 / RFC 9449 §10)
* - `wrongDpopJkt:true` → sends a dpop_jkt that does not match the
* token-request proof key; fails sep-1932-client-dpop-jkt (AS returns 400)
*/
export interface DpopClientOptions {
scheme: 'DPoP' | 'Bearer';
Expand All @@ -38,6 +42,10 @@ export interface DpopClientOptions {
handleAsNonce: boolean;
/** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */
handleRsNonce: boolean;
/** Include dpop_jkt on the authorization request (RFC 9449 §10). Default true. */
sendDpopJkt?: boolean;
/** Send a dpop_jkt that does not match the token-request proof key. */
wrongDpopJkt?: boolean;
}

const REDIRECT_URI = 'http://127.0.0.1:9876/callback';
Expand Down Expand Up @@ -90,14 +98,26 @@ export async function runDpopClient(
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: clientId,
state,
redirect_uri: REDIRECT_URI,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
}).toString()}`;
});
// RFC 9449 §10: bind the authorization code to this DPoP key. Default on so
// the compliant fixture and the other single-defect variants send a matching
// dpop_jkt unless they are the dedicated omit/mismatch clients.
if (options.sendDpopJkt !== false) {
authorizeParams.set(
'dpop_jkt',
options.wrongDpopJkt
? (await generateDpopKeyPair()).thumbprint
: keyPair.thumbprint
);
}
const authorizeUrl = `${authorizationEndpoint}?${authorizeParams.toString()}`;
const authorizeResponse = await request(authorizeUrl, { method: 'GET' });
await authorizeResponse.body.text().catch(() => undefined);
const location = authorizeResponse.headers['location'];
Expand Down
59 changes: 56 additions & 3 deletions src/scenarios/client/auth/dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from './helpers/dpopResourceAuth';
import { SpecReferences } from './spec-references';
import { collapseDuplicateChecks } from '../../../checks/collapse';
import { notTestable } from '../../untestable';

const PRM_PATH = '/.well-known/oauth-protected-resource/mcp';

Expand Down Expand Up @@ -76,9 +77,25 @@ const CHECK_DEFS: Record<
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_RS_NONCE
]
},
'sep-1932-client-dpop-jkt': {
name: 'DpopAuthCodeBinding',
description:
'Client binds the authorization code to its DPoP key via the dpop_jkt authorization request parameter (RFC 9449 §10)',
specReferences: [
SpecReferences.SEP_1932_DPOP,
SpecReferences.DPOP_EXTENSION,
SpecReferences.RFC_9449_DPOP_JKT
]
}
};

/**
* SEP-1932 requires the client to bind the authorization code to its DPoP key
* with `dpop_jkt` (RFC 9449 §10). Omission is a SHOULD miss.
Comment on lines +94 to +95

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good news first: per the conversation thread, the severity below is staying exactly as you built it — SEP-1932 is adopting a SHOULD for authorization code binding (spec text in modelcontextprotocol/ext-auth#35), SHOULD maps to WARNING under the suite's convention, so DPOP_JKT_NOT_SENT_STATUS is correct as-is.

What needs fixing is one word of provenance: "requires" overstates the keyword, and the comment contradicts itself — the next sentence already calls omission "a SHOULD miss." Since the keyword was genuinely contested in the thread, the paper trail should show this check grades a SHOULD, not a MUST.

Suggested change
* SEP-1932 requires the client to bind the authorization code to its DPoP key
* with `dpop_jkt` (RFC 9449 §10). Omission is a SHOULD miss.
* SEP-1932 recommends (SHOULD) that the client bind the authorization code to
* its DPoP key with `dpop_jkt` — OPTIONAL in RFC 9449 §10, tightened to SHOULD
* by the MCP profile. Omission is a SHOULD miss.

Generated with the aid of Claude (Fable 5).

*/
const DPOP_JKT_NOT_SENT_STATUS: CheckStatus = 'WARNING';

/**
* Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449).
*
Expand All @@ -91,15 +108,16 @@ const CHECK_DEFS: Record<
*
* - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline.
* Neither the AS nor the MCP server issues a nonce challenge; the client
* completes the flow with plain proofs. Emits three checks:
* completes the flow with plain proofs. Emits four checks:
* · token acquisition — a valid DPoP proof at the token request, obtaining
* a sender-constrained token (RFC 9449 §5);
* · the authorization code is bound to the DPoP key via `dpop_jkt` (§10);
* · the token is presented with the `DPoP` Authorization scheme (§7.1);
* · a fresh, well-formed DPoP proof accompanies each request (unique `jti`).
*
* - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both
* require a server-provided nonce (§8/§9), exercising the client's nonce
* handling. Emits the three baseline checks plus two more:
* handling. Emits the four baseline checks plus two more:
* · the client retries the token request with the AS-supplied nonce (§8);
* · the client retries the MCP request with the server-supplied nonce (§9).
*/
Expand All @@ -108,7 +126,8 @@ function newTokenReqObs(): DpopTokenRequestObservation {
recorded: false,
validProof: false,
asNonceChallengeIssued: false,
asNonceHonored: false
asNonceHonored: false,
dpopJktMatched: false
};
}

Expand Down Expand Up @@ -185,6 +204,7 @@ export class DPoPClientScenario implements Scenario {
const checks: ConformanceCheck[] = [
...shared,
this.tokenRequestProofCheck(),
this.dpopJktCheck(),
this.authSchemeCheck(),
this.freshProofCheck()
];
Expand Down Expand Up @@ -244,6 +264,39 @@ export class DPoPClientScenario implements Scenario {
);
}

private dpopJktCheck(): ConformanceCheck {
const sent = this.tokenReqObs.dpopJktSent;
const matched = this.tokenReqObs.dpopJktMatched;
let status: CheckStatus;
let errorMessage: string | undefined;
let untestable = false;
if (!this.tokenReqObs.recorded || !this.tokenReqObs.validProof) {
status = 'WARNING';
untestable = true;
errorMessage = notTestable(
'the client did not present a valid token-endpoint DPoP proof, so dpop_jkt key agreement could not be evaluated'
);
} else if (sent !== undefined && matched) {
status = 'SUCCESS';
} else if (sent !== undefined) {
status = 'FAILURE';
errorMessage =
'Client sent dpop_jkt but it does not match the DPoP proof key used at the token endpoint';
} else {
status = DPOP_JKT_NOT_SENT_STATUS;
errorMessage =
'Client did not send dpop_jkt on the authorization request to bind the authorization code to its DPoP key';
}
return this.build('sep-1932-client-dpop-jkt', status, {
errorMessage,
details: {
dpopJktSent: sent,
dpopJktMatched: matched,
...(untestable ? { untestable: true } : {})
Comment on lines +272 to +295

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This untestable emission drops the reason key that every other untestable emitter includes. The #248 convention shape is details: { untestable: true, reason } — that's what the canonical untestableCheck() helper emits, and the one other hand-rolled emitter (tools.ts's list-order check) follows it too. Here the reason text exists only inside errorMessage, so anything that consumes the structured details (as the existing emitters allow) gets the flag without the why. Hand-rolling the emission is right — the helper can't carry the dpopJktSent/dpopJktMatched details — it's just the one missing key:

Suggested change
let untestable = false;
if (!this.tokenReqObs.recorded || !this.tokenReqObs.validProof) {
status = 'WARNING';
untestable = true;
errorMessage = notTestable(
'the client did not present a valid token-endpoint DPoP proof, so dpop_jkt key agreement could not be evaluated'
);
} else if (sent !== undefined && matched) {
status = 'SUCCESS';
} else if (sent !== undefined) {
status = 'FAILURE';
errorMessage =
'Client sent dpop_jkt but it does not match the DPoP proof key used at the token endpoint';
} else {
status = DPOP_JKT_NOT_SENT_STATUS;
errorMessage =
'Client did not send dpop_jkt on the authorization request to bind the authorization code to its DPoP key';
}
return this.build('sep-1932-client-dpop-jkt', status, {
errorMessage,
details: {
dpopJktSent: sent,
dpopJktMatched: matched,
...(untestable ? { untestable: true } : {})
let untestableReason: string | undefined;
if (!this.tokenReqObs.recorded || !this.tokenReqObs.validProof) {
status = 'WARNING';
untestableReason =
'the client did not present a valid token-endpoint DPoP proof, so dpop_jkt key agreement could not be evaluated';
errorMessage = notTestable(untestableReason);
} else if (sent !== undefined && matched) {
status = 'SUCCESS';
} else if (sent !== undefined) {
status = 'FAILURE';
errorMessage =
'Client sent dpop_jkt but it does not match the DPoP proof key used at the token endpoint';
} else {
status = DPOP_JKT_NOT_SENT_STATUS;
errorMessage =
'Client did not send dpop_jkt on the authorization request to bind the authorization code to its DPoP key';
}
return this.build('sep-1932-client-dpop-jkt', status, {
errorMessage,
details: {
dpopJktSent: sent,
dpopJktMatched: matched,
...(untestableReason
? { untestable: true, reason: untestableReason }
: {})

Verified against the PR head with this change applied: tsc --noEmit clean, prettier clean, index.test.ts 70/70.

Generated with the aid of Claude (Fable 5).

}
});
}

private tokenRequestProofCheck(): ConformanceCheck {
let status: CheckStatus;
let errorMessage: string | undefined;
Expand Down
157 changes: 157 additions & 0 deletions src/scenarios/client/auth/helpers/createAuthServer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import type { ConformanceCheck } from '../../../../types';
import { testScenarioContext } from '../../../../mock-server/testing';
import {
generateDpopKeyPair,
buildDpopProof,
type DpopKeyPair
} from './dpopProof';
import {
createAuthServer,
type DpopTokenRequestObservation
} from './createAuthServer';
import { ServerLifecycle } from './serverLifecycle';

const REDIRECT = 'http://127.0.0.1:9876/callback';

function newObs(): DpopTokenRequestObservation {
return {
recorded: false,
validProof: false,
asNonceChallengeIssued: false,
asNonceHonored: false,
dpopJktMatched: false
};
}

async function requestAuthorizationCode(
base: string,
keyPair: DpopKeyPair
): Promise<string> {
const authorizeUrl = `${base}/authorize?${new URLSearchParams({
response_type: 'code',
client_id: 'test',
redirect_uri: REDIRECT,
code_challenge: 'x',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests run the AS through a deliberately broken PKCE exchange. 'x'/'x' is not a valid S256 pair ('x' is also well under RFC 7636 §4.1's 43-char verifier minimum), so every run of this file records a pkce-verifier-matches-challenge FAILURE into the checks array as a side effect (verified with a probe). The tests still pass because this test AS observes PKCE rather than enforcing it — but that makes the fixture silently dependent on non-enforcement, and it means the §10 behavior under test is being exercised in a run that's simultaneously failing an unrelated conformance check.

A real pair costs a few lines (both helpers pick it up; can't put this in one suggestion block since it touches the imports, a new constant, and line 64):

import { createHash } from 'node:crypto';

// A real S256 pair (RFC 7636 §4.1: verifier 43-128 chars), so these tests
// don't depend on the test AS tolerating an invalid PKCE exchange.
const CODE_VERIFIER = 'dpop-jkt-tests-code-verifier-0123456789-0123456789a';
const CODE_CHALLENGE = createHash('sha256')
  .update(CODE_VERIFIER)
  .digest('base64url');

…then code_challenge: CODE_CHALLENGE here and code_verifier: CODE_VERIFIER at line 64. Verified against the PR head: 2/2 pass with this change.

Generated with the aid of Claude (Fable 5).

code_challenge_method: 'S256',
dpop_jkt: keyPair.thumbprint
}).toString()}`;
const response = await fetch(authorizeUrl, { redirect: 'manual' });
return new URL(response.headers.get('location')!).searchParams.get('code')!;
}

async function exchangeAuthorizationCode(
base: string,
code: string,
keyPair: DpopKeyPair
): Promise<Response> {
const tokenEndpoint = `${base}/token`;
const proof = await buildDpopProof({
keyPair,
htm: 'POST',
htu: tokenEndpoint
});
return fetch(tokenEndpoint, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
dpop: proof
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT,
code_verifier: 'x',
client_id: 'test'
})
});
}

describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => {
it('rejects authorization_code with 400 invalid_grant when dpop_jkt does not match the proof key', async () => {
const checks: ConformanceCheck[] = [];
const lifecycle = new ServerLifecycle();
const obs = newObs();
const app = createAuthServer(
testScenarioContext(),
checks,
lifecycle.getUrl,
{
dpopSigningAlgValuesSupported: ['ES256'],
dpopTokenRequestObs: obs,
loggingEnabled: false
}
);
await lifecycle.start(app);
try {
const proofKp = await generateDpopKeyPair();
const otherKp = await generateDpopKeyPair();
const code = await requestAuthorizationCode(lifecycle.getUrl(), otherKp);
const tokenRes = await exchangeAuthorizationCode(
lifecycle.getUrl(),
code,
proofKp
);

expect(tokenRes.status).toBe(400);
const body = (await tokenRes.json()) as {
error?: string;
error_description?: string;
};
expect(body.error).toBe('invalid_grant');
expect(body.error_description).toBe(
'dpop_jkt does not match the DPoP proof key'
);
expect(obs.dpopJktSent).toBe(otherKp.thumbprint);
expect(obs.dpopJktMatched).toBe(false);
expect(obs.validProof).toBe(true);
} finally {
await lifecycle.stop();
}
});

it('binds dpop_jkt to each authorization code across overlapping flows', async () => {
const checks: ConformanceCheck[] = [];
const lifecycle = new ServerLifecycle();
const obs = newObs();
const app = createAuthServer(
testScenarioContext(),
checks,
lifecycle.getUrl,
{
dpopSigningAlgValuesSupported: ['ES256'],
dpopTokenRequestObs: obs,
loggingEnabled: false
}
);
await lifecycle.start(app);
try {
const first = await generateDpopKeyPair();
const second = await generateDpopKeyPair();
const firstCode = await requestAuthorizationCode(
lifecycle.getUrl(),
first
);
const secondCode = await requestAuthorizationCode(
lifecycle.getUrl(),
second
);

for (const [code, keyPair] of [
[firstCode, first],
[secondCode, second]
] as const) {
const response = await exchangeAuthorizationCode(
lifecycle.getUrl(),
code,
keyPair
);
expect(response.status).toBe(200);
expect(obs.dpopJktSent).toBe(keyPair.thumbprint);
expect(obs.dpopJktMatched).toBe(true);
}
} finally {
await lifecycle.stop();
}
});
});
Loading
Loading