feat(client): add DPoP (RFC 9449) sender-constrained token support (#2629)

Co-authored-by: Paul Carleton <paulc@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Bhavani Shankar Garikapati 2026-08-31 10:00:18 -07:00 • committed by GitHub
commit dcc01028ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1753 additions and 30 deletions

View file

@ -29,14 +29,12 @@ client:
# (none: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the
# last referee-side gap — conformance#361 callback-iss — closed at alpha.6)
#
# --- SEP-1932 (DPoP) / SEP-1933 (WIF) — extension-tagged auth scenarios, new in the alpha.10 referee ---
# The OAuth client implements neither DPoP proofs (RFC 9449) nor the
# urn:ietf:params:oauth:grant-type:jwt-bearer grant, so every check in
# these scenarios fails. Client-side extension scenarios are selected only
# by `--suite all`; the 2026 leg cannot flag them stale (extension
# --- SEP-1933 (WIF) — extension-tagged auth scenario, new in the alpha.10 referee ---
# The OAuth client does not implement the urn:ietf:params:oauth:grant-type:jwt-bearer
# grant, so every check in this scenario fails. Client-side extension scenarios are
# selected only by `--suite all`; the 2026 leg cannot flag it stale (extension
# scenarios never match a --spec-version filter).
- auth/dpop
- auth/dpop-nonce
# (SEP-1932 DPoP burned by the auth/dpop + auth/dpop-nonce client implementation.)
- auth/wif-jwt-bearer
server:

View file

@ -23,6 +23,7 @@ import {
import * as z from 'zod/v4';
import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider';
import { DpopOAuthProvider } from './helpers/dpopClient';
import { logger } from './helpers/logger';
import { handle401, withOAuthRetry } from './helpers/withOAuthRetry';
@ -505,6 +506,51 @@ registerScenarios(
runAuthClient
);
// ============================================================================
// DPoP sender-constrained tokens (SEP-1932 / RFC 9449, draft extension)
// ============================================================================
/**
* Identical to {@linkcode runAuthClient} except the provider carries a DPoP session
* ({@linkcode DpopOAuthProvider}) — every DPoP-specific behavior (token-request proof, the
* `DPoP` Authorization scheme, a fresh per-request proof, AS/RS `use_dpop_nonce` retry) is the
* SDK's own (`@modelcontextprotocol/client`'s `dpop.ts` / `auth.ts` / `streamableHttp.ts`),
* exercised end-to-end here rather than re-implemented. One handler drives both `auth/dpop`
* (nonce-less) and `auth/dpop-nonce` — which posture runs depends only on whether the referee's
* authorization server / MCP server issue a nonce challenge, which the SDK reacts to automatically.
*/
async function runDpopAuthClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-dpop-auth-client', version: '1.0.0' },
{ capabilities: {}, versionNegotiation: { mode: 'auto' } }
);
const provider = new DpopOAuthProvider(
'http://localhost:3000/callback',
{ client_name: 'test-dpop-auth-client', redirect_uris: ['http://localhost:3000/callback'] },
CIMD_CLIENT_METADATA_URL
);
const dpopFetch = withOAuthRetry('test-dpop-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL, provider)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: dpopFetch
});
await client.connect(transport);
logger.debug('Successfully connected to MCP server (DPoP)');
await client.listTools();
logger.debug('Successfully listed tools');
await client.callTool({ name: 'test-tool', arguments: {} });
logger.debug('Successfully called tool');
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenarios(['auth/dpop', 'auth/dpop-nonce'], runDpopAuthClient);
// ============================================================================
// Client Credentials scenarios
// ============================================================================

View file

@ -0,0 +1,29 @@
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
import { DpopSession } from '@modelcontextprotocol/client';
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
/**
* {@linkcode ConformanceOAuthProvider} plus a DPoP signing session (SEP-1932 / RFC 9449).
*
* Adding `dpop()` is the *only* thing this class does — every DPoP-specific behavior (a proof at
* the token endpoint, presenting the token with the `DPoP` scheme, a fresh proof per MCP request,
* retrying on an AS/RS `use_dpop_nonce` challenge) lives in `@modelcontextprotocol/client` itself
* (`dpop.ts` / `auth.ts` / `streamableHttp.ts`) and is exercised end-to-end through this provider,
* not re-implemented here. The same handler drives both the `auth/dpop` (nonce-less) and
* `auth/dpop-nonce` postures — which one is exercised depends entirely on whether the test
* authorization server / MCP server issue a `use_dpop_nonce` challenge, which the SDK reacts to
* automatically.
*/
export class DpopOAuthProvider extends ConformanceOAuthProvider {
private readonly _dpopSession: Promise<DpopSession>;
constructor(redirectUrl: string | URL, clientMetadata: OAuthClientMetadata, clientMetadataUrl?: string | URL) {
super(redirectUrl, clientMetadata, clientMetadataUrl);
this._dpopSession = DpopSession.create();
}
async dpop(): Promise<DpopSession> {
return this._dpopSession;
}
}

View file

@ -4,7 +4,8 @@ import {
computeScopeUnion,
extractWWWAuthenticateParams,
isStrictScopeSuperset,
UnauthorizedError
UnauthorizedError,
withDpopFromProvider
} from '@modelcontextprotocol/client';
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
@ -94,7 +95,14 @@ export const withOAuthRetry = (
},
clientMetadataUrl
);
return (next: FetchLike) => {
return (baseNext: FetchLike) => {
// Same composition as the SDK's withOAuth: DPoP request-signing sits *below* this
// Bearer/re-auth layer so it binds proofs to the real request and retries use_dpop_nonce
// challenges on every attempt. It is a pass-through unless the provider implements dpop()
// (helpers/dpopClient.ts). auth() keeps the unwrapped fetch — token-endpoint DPoP is the
// SDK's executeTokenRequest's job.
const next = withDpopFromProvider(provider)(baseNext);
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
@ -113,7 +121,7 @@ export const withOAuthRetry = (
// Handle 401/403 responses by attempting re-authentication
if (response.status === 401 || response.status === 403) {
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
await handle401Fn(response, provider, next, serverUrl);
await handle401Fn(response, provider, baseNext, serverUrl);
response = await makeRequest();
}