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:
parent
70de0c8b56
commit
dcc01028ff
4 changed files with 1753 additions and 30 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
29
test/conformance/src/helpers/dpopClient.ts
Normal file
29
test/conformance/src/helpers/dpopClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue