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.
*/
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 } : {})
}
});
}

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',
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