Client Auth: DPoP Sender-Constrained Tokens (SEP-1932) (#394)
* Add DPoP client conformance scenario + shared DPoP helpers (SEP-1932, #368) Client-anchored packaging: carries the shared DPoP foundation (dpopProof / dpopToken helpers, the createAuthServer DPoP core, and sep-1932.yaml) together with the client scenario, so it can merge first; the server (#369) and authorization-server (#370) scenarios are follow-ups that depend on it. DPoP is treated as a draft/not-yet-official feature (mirroring the WIF SEP-1933 scenario): the scenario's source is `introducedIn: DRAFT_PROTOCOL_VERSION` and it is registered in draftScenariosList (informational, not scored). It is NOT registered in EXTENSION_IDS, since the ext-auth DPoP extension is still on a branch rather than merged. Client checks (the two extension MUSTs, RFC 9449 §7.1 / §4.2-4.3): - sep-1932-client-dpop-auth-scheme — token presented with the DPoP scheme. - sep-1932-client-fresh-proof — a fresh, well-formed DPoP proof per request. The test MCP server judges the client via an opt-in DPoP middleware (dpopResourceAuth) passed through createServer's authMiddleware hook; a compliant example client (covered by the Client Draft Scenarios loop) plus deliberately- broken bearer/replay variants drive the pass/fail acceptance tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * client/dpop: split into nonce-less baseline + nonce-required scenario Server-provided nonces are optional in RFC 9449 (AS §8 "MAY", RS §9 "can also choose"), and the nonce-less flow is the common case. The single auth/dpop scenario hard-wired nonce enforcement on both the test AS and MCP server, so a plain nonce-less proof was never accepted end-to-end and a client that only does plain proofs was never exercised on its happy path. Parameterize DPoPClientScenario with `requireNonce` and register it twice: - auth/dpop (requireNonce=false) — nonce-less baseline; emits the three baseline checks (token-request-proof, dpop-auth-scheme, fresh-proof). - auth/dpop-nonce (requireNonce=true) — prior behavior; adds as-nonce and rs-nonce (five checks). Check IDs are reused (no new IDs, no sep-1932.yaml change); the two nonce checks are emitted only by auth/dpop-nonce, so traceability stays intact. Add auth-test-dpop-no-nonce.ts (nonce-incapable but otherwise compliant) asserting SUCCESS against auth/dpop — proof that a client with no nonce handling still completes DPoP when the server does not require a nonce. Baseline negatives stay on auth/dpop (nonce-independent); the two nonce negatives are re-pointed to auth/dpop-nonce. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * client/dpop: round-4 nonce-surface correctness fixes Address review findings on the auth/dpop-nonce posture: - Record the token-request proof observation BEFORE the §8 nonce challenge, so a client that is challenged but does not retry is no longer mis-reported by token-request-proof as "never completed a token request" (its proof was just validated). (#2) - Record the resource-request jti/replay on ANY valid proof (before the §9 nonce gate), so fresh-proof is never asserted on zero evidence and a client that never honours the RS nonce fails ONLY rs-nonce. (#5) - Require challengeIssued && honored for as-nonce/rs-nonce SUCCESS, closing a vacuous-pass path (a client that pre-sends the nonce is never challenged). (#3a) - Gate the AS nonce observation on the authorization_code exchange, matching recordTokenRequestProof (a refresh exchange must not satisfy §8). (#3c) - Collapse duplicate shared token-flow check IDs (token-request, pkce-*) that the §8 round-trip re-POST would otherwise emit twice. (#4) - Example client: retry the token request only on a use_dpop_nonce error code, not any 400 carrying a DPoP-Nonce header (consistent with the RS path). (#6) - Fix the DpopTokenRequestObservation docstring ("last write wins" → sticky-failure). (#7) Adds an expectedSuccessSlugs option to the client test helper and tightens the two nonce negative tests to pin the now-clean behavior (token-request-proof SUCCESS for no-as-nonce; only rs-nonce fails for no-rs-nonce). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * client/dpop: round-5 fixes — dedupe masking, wording, replay decision - dedupeSharedChecks was masking failures: it kept the last occurrence of a duplicate check ID regardless of status and ran in BOTH postures, so a FAILURE on a challenged POST (or an earlier attempt in a restarted flow) was silently dropped. Replace with an exported, unit-tested collapseDuplicateChecks that prefers the MOST-SEVERE occurrence, and only apply it in the nonce posture (the baseline keeps genuinely distinct repeated attempts). (R5 #1) - Reword the token-request-proof description: it can be SUCCESS for a client that presented a valid proof but never obtained a token (challenged, no retry), so it no longer claims "obtaining a DPoP-bound access token". (R5 #2) - Reword the as-nonce/rs-nonce failure messages to cover a client that DID retry but whose retry proof was rejected (not only "did not retry"). (R5 #3) - Document the deliberate replay decision: recording jti on challenged requests means re-sending the identical proof across the challenge/retry boundary trips replay detection — a genuine RFC 9449 §4.2 jti-uniqueness violation. (R5 #5) Adds src/scenarios/client/auth/dpop.test.ts unit-testing collapseDuplicateChecks (prefers failures, keeps INFO, preserves order) — the dedupe fix is now pinned. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * client/dpop: round-6 polish — pin collapse wiring; docstring - Add an integration assertion that the §8/§9 double-POST is collapsed to one token-request/pkce-* entry each in auth/dpop-nonce, so the requireNonce gating of collapseDuplicateChecks can't be silently deleted or inverted (previously mutation-survivable). runClientAgainstScenario now returns the emitted checks so callers can make occurrence-level assertions (backward-compatible). - Docstring: note the severity ladder ranks SKIPPED below SUCCESS, and that equal-severity ties keep the last occurrence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5ad96dbfbc
commit
6e726734f5
9 changed files with 2713 additions and 4 deletions
21
examples/clients/typescript/auth-test-dpop-bearer.ts
Normal file
21
examples/clients/typescript/auth-test-dpop-bearer.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { runDpopClient } from './helpers/dpopClientFlow';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
|
||||
/**
|
||||
* Broken DPoP client: builds correct per-request proofs but presents the
|
||||
* DPoP-bound token with the `Bearer` scheme instead of `DPoP`. Isolates a
|
||||
* failure of sep-1932-client-dpop-auth-scheme (RFC 9449 §7.1).
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'Bearer',
|
||||
freshProofPerRequest: true,
|
||||
sendTokenRequestProof: true,
|
||||
handleAsNonce: true,
|
||||
handleRsNonce: true
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-dpop-bearer <server-url>');
|
||||
22
examples/clients/typescript/auth-test-dpop-no-as-nonce.ts
Normal file
22
examples/clients/typescript/auth-test-dpop-no-as-nonce.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { runDpopClient } from './helpers/dpopClientFlow';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
|
||||
/**
|
||||
* Broken DPoP client: ignores the authorization server's `use_dpop_nonce`
|
||||
* challenge at the token endpoint (RFC 9449 §8) — it does not retry the token
|
||||
* request with the supplied nonce. Isolates a failure of
|
||||
* sep-1932-client-as-nonce.
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'DPoP',
|
||||
freshProofPerRequest: true,
|
||||
sendTokenRequestProof: true,
|
||||
handleAsNonce: false,
|
||||
handleRsNonce: true
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-as-nonce <server-url>');
|
||||
25
examples/clients/typescript/auth-test-dpop-no-nonce.ts
Normal file
25
examples/clients/typescript/auth-test-dpop-no-nonce.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { runDpopClient } from './helpers/dpopClientFlow';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
|
||||
/**
|
||||
* Nonce-incapable but otherwise compliant DPoP client (SEP-1932 / RFC 9449):
|
||||
* presents the DPoP-bound token with the `DPoP` Authorization scheme and a fresh
|
||||
* proof on every request, but implements NO `use_dpop_nonce` handling. Against
|
||||
* the nonce-less `auth/dpop` scenario (where neither server issues a challenge)
|
||||
* it completes the flow successfully — proving that a client which does not
|
||||
* support nonces still passes when the server does not require one (the common
|
||||
* case, since server nonces are OPTIONAL in RFC 9449 §8/§9).
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'DPoP',
|
||||
freshProofPerRequest: true,
|
||||
sendTokenRequestProof: true,
|
||||
handleAsNonce: false,
|
||||
handleRsNonce: false
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-nonce <server-url>');
|
||||
22
examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts
Normal file
22
examples/clients/typescript/auth-test-dpop-no-rs-nonce.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { runDpopClient } from './helpers/dpopClientFlow';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
|
||||
/**
|
||||
* Broken DPoP client: obtains the token correctly (handles the AS nonce) but
|
||||
* ignores the MCP server's `use_dpop_nonce` challenge at the resource
|
||||
* (RFC 9449 §9) — it does not retry the request with the supplied nonce.
|
||||
* Isolates a failure of sep-1932-client-rs-nonce.
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'DPoP',
|
||||
freshProofPerRequest: true,
|
||||
sendTokenRequestProof: true,
|
||||
handleAsNonce: true,
|
||||
handleRsNonce: false
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-rs-nonce <server-url>');
|
||||
27
examples/clients/typescript/auth-test-dpop-no-token-proof.ts
Normal file
27
examples/clients/typescript/auth-test-dpop-no-token-proof.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { runDpopClient } from './helpers/dpopClientFlow';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
|
||||
/**
|
||||
* Broken DPoP client: never sends a DPoP proof at the token endpoint, so it
|
||||
* never asks for a sender-constrained token — the AS falls back to an unbound
|
||||
* Bearer token. Isolates a failure of sep-1932-client-token-request-proof
|
||||
* (RFC 9449 §5). Because the resulting token is unbound, the resource-side
|
||||
* binding check (sep-1932-client-fresh-proof) also fails as a consequence.
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'DPoP',
|
||||
freshProofPerRequest: true,
|
||||
sendTokenRequestProof: false,
|
||||
handleAsNonce: true,
|
||||
handleRsNonce: true
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(
|
||||
runClient,
|
||||
import.meta.url,
|
||||
'auth-test-dpop-no-token-proof <server-url>'
|
||||
);
|
||||
21
examples/clients/typescript/auth-test-dpop-replay.ts
Normal file
21
examples/clients/typescript/auth-test-dpop-replay.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { runDpopClient } from './helpers/dpopClientFlow';
|
||||
import { runAsCli } from './helpers/cliRunner';
|
||||
|
||||
/**
|
||||
* Broken DPoP client: uses the DPoP scheme correctly but reuses a single proof
|
||||
* (same `jti`) across requests instead of a fresh one each time. Isolates a
|
||||
* failure of sep-1932-client-fresh-proof.
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'DPoP',
|
||||
freshProofPerRequest: false,
|
||||
sendTokenRequestProof: true,
|
||||
handleAsNonce: true,
|
||||
handleRsNonce: true
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-dpop-replay <server-url>');
|
||||
20
examples/clients/typescript/auth-test-dpop.ts
Normal file
20
examples/clients/typescript/auth-test-dpop.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
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.
|
||||
*/
|
||||
export async function runClient(serverUrl: string): Promise<void> {
|
||||
await runDpopClient(serverUrl, {
|
||||
scheme: 'DPoP',
|
||||
freshProofPerRequest: true,
|
||||
sendTokenRequestProof: true,
|
||||
handleAsNonce: true,
|
||||
handleRsNonce: true
|
||||
});
|
||||
}
|
||||
|
||||
runAsCli(runClient, import.meta.url, 'auth-test-dpop <server-url>');
|
||||
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from './helpers/withOAuthRetry.js';
|
||||
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider.js';
|
||||
import { runClient as issValidationClient } from './auth-test-iss-validation.js';
|
||||
import { runClient as dpopClient } from './auth-test-dpop.js';
|
||||
import { logger } from './helpers/logger.js';
|
||||
|
||||
/**
|
||||
|
|
@ -854,6 +855,13 @@ registerScenario(
|
|||
runEnterpriseManagedAuthorization
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// DPoP client conformance (SEP-1932)
|
||||
// ============================================================================
|
||||
|
||||
registerScenario('auth/dpop', dpopClient);
|
||||
registerScenario('auth/dpop-nonce', dpopClient);
|
||||
|
||||
// ============================================================================
|
||||
// MRTR client conformance (SEP-2322)
|
||||
// ============================================================================
|
||||
|
|
|
|||
234
examples/clients/typescript/helpers/dpopClientFlow.ts
Normal file
234
examples/clients/typescript/helpers/dpopClientFlow.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { request } from 'undici';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
generateDpopKeyPair,
|
||||
buildDpopProof
|
||||
} from '../../../../src/scenarios/client/auth/helpers/dpopProof';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Shared DPoP client flow (SEP-1932 / RFC 9449). Acquires a DPoP-bound access
|
||||
* token via the authorization_code + PKCE grant (with a DPoP proof at the token
|
||||
* endpoint), then opens an MCP session presenting the token to the resource.
|
||||
*
|
||||
* The OAuth flow is hand-rolled rather than reusing ConformanceOAuthProvider /
|
||||
* withOAuthRetry because the SDK's OAuth provider offers no hook to attach a
|
||||
* DPoP proof to the token-endpoint request, which is required to obtain a bound
|
||||
* token. The MCP session itself does use the SDK Client (via a fetch wrapper).
|
||||
*
|
||||
* Parameterized so the compliant client and the deliberately-broken variants
|
||||
* differ by exactly one behaviour:
|
||||
* - `scheme: 'Bearer'` → fails sep-1932-client-dpop-auth-scheme
|
||||
* - `freshProofPerRequest:false` → reuses one proof, fails sep-1932-client-fresh-proof
|
||||
* - `sendTokenRequestProof:false`→ omits the token-endpoint proof, so the AS
|
||||
* issues an unbound Bearer token; fails sep-1932-client-token-request-proof
|
||||
* (and, downstream, the resource binding check, since the token isn't bound)
|
||||
* - `handleAsNonce:false` → ignores the token endpoint's `use_dpop_nonce`
|
||||
* 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
|
||||
*/
|
||||
export interface DpopClientOptions {
|
||||
scheme: 'DPoP' | 'Bearer';
|
||||
freshProofPerRequest: boolean;
|
||||
sendTokenRequestProof: boolean;
|
||||
/** Retry the token request with the AS-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §8). */
|
||||
handleAsNonce: boolean;
|
||||
/** Retry an MCP request with the server-supplied nonce on a use_dpop_nonce challenge (RFC 9449 §9). */
|
||||
handleRsNonce: boolean;
|
||||
}
|
||||
|
||||
const REDIRECT_URI = 'http://127.0.0.1:9876/callback';
|
||||
|
||||
export async function runDpopClient(
|
||||
serverUrl: string,
|
||||
options: DpopClientOptions
|
||||
): Promise<void> {
|
||||
const keyPair = await generateDpopKeyPair();
|
||||
|
||||
// 1. Discover the authorization server via Protected Resource Metadata.
|
||||
const prmUrl = new URL(
|
||||
'/.well-known/oauth-protected-resource/mcp',
|
||||
serverUrl
|
||||
);
|
||||
const prm = await (await fetch(prmUrl.toString())).json();
|
||||
const authServerUrl: string = prm.authorization_servers[0];
|
||||
|
||||
// 2. Authorization server metadata.
|
||||
const asMeta = await (
|
||||
await fetch(
|
||||
new URL(
|
||||
'/.well-known/oauth-authorization-server',
|
||||
authServerUrl
|
||||
).toString()
|
||||
)
|
||||
).json();
|
||||
const authorizationEndpoint: string = asMeta.authorization_endpoint;
|
||||
const tokenEndpoint: string = asMeta.token_endpoint;
|
||||
const registrationEndpoint: string = asMeta.registration_endpoint;
|
||||
|
||||
// 3. Dynamic client registration.
|
||||
const reg = await (
|
||||
await fetch(registrationEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: 'conformance-dpop-client',
|
||||
redirect_uris: [REDIRECT_URI],
|
||||
application_type: 'native'
|
||||
})
|
||||
})
|
||||
).json();
|
||||
const clientId: string = reg.client_id;
|
||||
|
||||
// 4. Authorization request (PKCE). The test AS redirects straight to the
|
||||
// redirect_uri, so read the code from the Location header (no callback needed).
|
||||
const state = randomBytes(16).toString('base64url');
|
||||
const codeVerifier = randomBytes(32).toString('base64url');
|
||||
const codeChallenge = createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
const authorizeUrl = `${authorizationEndpoint}?${new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
state,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
}).toString()}`;
|
||||
const authorizeResponse = await request(authorizeUrl, { method: 'GET' });
|
||||
await authorizeResponse.body.text().catch(() => undefined);
|
||||
const location = authorizeResponse.headers['location'];
|
||||
const locationStr = Array.isArray(location) ? location[0] : location;
|
||||
if (!locationStr) {
|
||||
throw new Error('Authorization endpoint did not redirect with a code');
|
||||
}
|
||||
const code = new URL(locationStr).searchParams.get('code');
|
||||
if (!code) throw new Error('No authorization code in redirect');
|
||||
|
||||
// 5. Token request with a DPoP proof → DPoP-bound access token. The broken
|
||||
// `sendTokenRequestProof:false` variant omits the proof, so the AS issues an
|
||||
// unbound Bearer token instead.
|
||||
const tokenReqBody = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: codeVerifier,
|
||||
client_id: clientId
|
||||
}).toString();
|
||||
const requestToken = async (nonce?: string): Promise<Response> => {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
if (options.sendTokenRequestProof) {
|
||||
headers.dpop = await buildDpopProof({
|
||||
keyPair,
|
||||
htm: 'POST',
|
||||
// RFC 9449 §4.2: htu carries no query/fragment (the token endpoint URL
|
||||
// may legally have a query, so strip it here).
|
||||
htu: stripQuery(tokenEndpoint),
|
||||
...(nonce ? { nonce } : {})
|
||||
});
|
||||
}
|
||||
return fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: tokenReqBody
|
||||
});
|
||||
};
|
||||
let tokenResponse = await requestToken();
|
||||
// RFC 9449 §8: the AS may answer with `use_dpop_nonce` (HTTP 400 + DPoP-Nonce);
|
||||
// a conformant client retries the token request with the supplied nonce. Match
|
||||
// on the `use_dpop_nonce` error code (not merely any 400 carrying a nonce), so
|
||||
// an unrelated error (e.g. invalid_grant) that an AS proactively decorates with
|
||||
// a DPoP-Nonce header does not burn the retry and mask the real failure —
|
||||
// consistent with the resource-side check below.
|
||||
const asNonce = tokenResponse.headers.get('DPoP-Nonce');
|
||||
if (tokenResponse.status === 400 && asNonce && options.handleAsNonce) {
|
||||
const challenge = await tokenResponse
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => ({}) as { error?: string });
|
||||
if (challenge?.error === 'use_dpop_nonce') {
|
||||
tokenResponse = await requestToken(asNonce);
|
||||
}
|
||||
}
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token request failed: HTTP ${tokenResponse.status}`);
|
||||
}
|
||||
const tokenBody = await tokenResponse.json();
|
||||
const accessToken: string = tokenBody.access_token;
|
||||
logger.debug(`Obtained ${tokenBody.token_type} access token`);
|
||||
|
||||
// 6. MCP session — present the token to the resource with a per-request proof.
|
||||
// On a `use_dpop_nonce` challenge (RFC 9449 §9) a conformant client retries
|
||||
// with the server-supplied nonce embedded in the proof, and carries it on
|
||||
// subsequent requests.
|
||||
const mcpUrl = `${serverUrl}`;
|
||||
let reusableProof: string | undefined;
|
||||
let rsNonce: string | undefined;
|
||||
const dpopFetch = async (
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> => {
|
||||
const method = (init?.method ?? 'POST').toUpperCase();
|
||||
const htu = stripQuery(
|
||||
typeof input === 'string' ? input : input.toString()
|
||||
);
|
||||
const attempt = async (): Promise<Response> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Authorization', `${options.scheme} ${accessToken}`);
|
||||
let proof: string;
|
||||
if (options.freshProofPerRequest || !reusableProof) {
|
||||
proof = await buildDpopProof({
|
||||
keyPair,
|
||||
htm: method,
|
||||
htu,
|
||||
accessToken,
|
||||
...(rsNonce ? { nonce: rsNonce } : {})
|
||||
});
|
||||
if (!options.freshProofPerRequest) reusableProof = proof;
|
||||
} else {
|
||||
proof = reusableProof;
|
||||
}
|
||||
headers.set('DPoP', proof);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
let res = await attempt();
|
||||
const nonce = res.headers.get('DPoP-Nonce');
|
||||
if (
|
||||
res.status === 401 &&
|
||||
nonce &&
|
||||
options.handleRsNonce &&
|
||||
(res.headers.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce')
|
||||
) {
|
||||
rsNonce = nonce;
|
||||
reusableProof = undefined; // rebuild the proof carrying the nonce
|
||||
res = await attempt();
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'conformance-dpop-client', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
|
||||
fetch: dpopFetch as typeof fetch
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Connected to MCP server');
|
||||
await client.listTools();
|
||||
logger.debug('Listed tools');
|
||||
await client.callTool({ name: 'test-tool', arguments: {} });
|
||||
logger.debug('Called tool');
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
function stripQuery(url: string): string {
|
||||
const u = new URL(url);
|
||||
return `${u.origin}${u.pathname}`;
|
||||
}
|
||||
Loading…
Reference in a new issue