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
5 changed files with 1753 additions and 30 deletions

View file

@ -0,0 +1,246 @@
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth';
import { auth, executeTokenRequest, extractWWWAuthenticateParams } from '../../src/client/auth';
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions';
import { DpopSession } from '../../src/client/dpop';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
describe('executeTokenRequest — DPoP', () => {
let session: DpopSession;
let fetchFn: Mock;
beforeEach(async () => {
session = await DpopSession.create();
fetchFn = vi.fn();
});
it('signs a DPoP proof into the token request DPoP header', async () => {
fetchFn.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ access_token: 'tok', token_type: 'DPoP' })
});
await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
dpop: session,
fetchFn
});
const [, init] = fetchFn.mock.calls[0]!;
const headers = init.headers as Headers;
const proof = headers.get('DPoP');
expect(proof).toBeTruthy();
const payload = decodeJwtPart(proof!.split('.')[1]!);
expect(payload.htm).toBe('POST');
expect(payload.htu).toBe('https://as.example.com/token');
// No `ath`: the token request presents credentials to *obtain* a token, not an existing one.
expect(payload.ath).toBeUndefined();
});
it('retries once with a fresh nonce-carrying proof on a 400 use_dpop_nonce challenge', async () => {
fetchFn
.mockResolvedValueOnce({
ok: false,
status: 400,
headers: new Headers({ 'DPoP-Nonce': 'as-nonce-1' }),
clone() {
return this;
},
json: async () => ({ error: 'use_dpop_nonce' })
})
.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ access_token: 'tok', token_type: 'DPoP' })
});
const tokens = await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
dpop: session,
fetchFn
});
expect(tokens.access_token).toBe('tok');
expect(fetchFn).toHaveBeenCalledTimes(2);
const secondProof = (fetchFn.mock.calls[1]![1].headers as Headers).get('DPoP');
const firstProof = (fetchFn.mock.calls[0]![1].headers as Headers).get('DPoP');
expect(secondProof).not.toBe(firstProof); // fresh jti — never resend the identical proof
expect(decodeJwtPart(secondProof!.split('.')[1]!).nonce).toBe('as-nonce-1');
// The session also remembers the nonce for future requests to this origin.
expect(session.nonceFor('https://as.example.com')).toBe('as-nonce-1');
});
it('does not retry a second time (surfaces the error) when the nonce challenge repeats', async () => {
// A real Response (not a plain mock object): parseErrorResponse — reached once the single
// retry is exhausted and the challenge repeats — branches on `instanceof Response`.
fetchFn.mockImplementation(async () =>
Response.json(
{ error: 'use_dpop_nonce' },
{
status: 400,
headers: { 'DPoP-Nonce': 'as-nonce-1' }
}
)
);
await expect(
executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
dpop: session,
fetchFn
})
).rejects.toMatchObject({ code: 'use_dpop_nonce' });
// Exactly one retry: the initial nonce-less attempt, and one retry carrying the nonce.
expect(fetchFn).toHaveBeenCalledTimes(2);
});
it('re-runs addClientAuthentication for the nonce retry so a private_key_jwt client_assertion is not replayed', async () => {
const addClientAuthentication = createPrivateKeyJwtAuth({
issuer: 'client-1',
subject: 'client-1',
privateKey: 'a-string-secret-at-least-256-bits-long',
alg: 'HS256'
});
const assertions: string[] = [];
fetchFn.mockImplementation(async (_url: URL, init: RequestInit) => {
assertions.push((init.body as URLSearchParams).get('client_assertion')!);
return assertions.length === 1
? Response.json({ error: 'use_dpop_nonce' }, { status: 400, headers: { 'DPoP-Nonce': 'as-nonce-1' } })
: Response.json({ access_token: 'tok', token_type: 'DPoP' });
});
await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
addClientAuthentication,
dpop: session,
fetchFn
});
expect(assertions).toHaveLength(2);
// RFC 7521 §5.2 / RFC 7523 §3: an AS MAY enforce one-time use of assertions keyed on jti.
const [first, second] = assertions.map(a => decodeJwtPart(a.split('.')[1]!));
expect(second!.jti).not.toBe(first!.jti);
});
it('is unaffected when no dpop session is supplied (plain OAuth token requests keep working)', async () => {
fetchFn.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ access_token: 'tok', token_type: 'Bearer' })
});
await executeTokenRequest('https://as.example.com', {
tokenRequestParams: new URLSearchParams({ grant_type: 'client_credentials' }),
fetchFn
});
const [, init] = fetchFn.mock.calls[0]!;
expect((init.headers as Headers).has('DPoP')).toBe(false);
});
});
describe('auth() refresh — DPoP', () => {
const mockFetch = vi.fn();
let session: DpopSession;
let provider: OAuthClientProvider;
let tokenResponse: () => Response;
beforeEach(async () => {
mockFetch.mockReset();
vi.stubGlobal('fetch', mockFetch);
session = await DpopSession.create();
const tokens = vi.fn().mockResolvedValue({ access_token: 'old', token_type: 'DPoP', refresh_token: 'rt-1' });
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn().mockResolvedValue({ client_id: 'client-1' }),
tokens,
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
invalidateCredentials: vi.fn(async scope => {
if (scope === 'tokens' || scope === 'all') tokens.mockResolvedValue(undefined);
}),
dpop: () => session
};
mockFetch.mockImplementation(async (url: URL | string) => {
const urlString = url.toString();
if (urlString.includes('/.well-known/oauth-authorization-server')) {
return Response.json({
issuer: 'https://api.example.com',
authorization_endpoint: 'https://api.example.com/authorize',
token_endpoint: 'https://api.example.com/token',
response_types_supported: ['code'],
dpop_signing_alg_values_supported: ['ES256']
});
}
if (urlString === 'https://api.example.com/token') return tokenResponse();
return new Response(null, { status: 404 });
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('signs a DPoP proof into the refresh_token grant request', async () => {
tokenResponse = () => Response.json({ access_token: 'new', token_type: 'DPoP', refresh_token: 'rt-2' });
await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).resolves.toBe('AUTHORIZED');
const [, init] = mockFetch.mock.calls.find(c => c[0].toString() === 'https://api.example.com/token')!;
expect((init.body as URLSearchParams).get('grant_type')).toBe('refresh_token');
const proof = new Headers(init.headers).get('DPoP');
expect(decodeJwtPart(proof!.split('.')[1]!).htu).toBe('https://api.example.com/token');
});
it('falls back to a fresh authorization when the AS rejects the refresh with invalid_dpop_proof', async () => {
// e.g. the (non-extractable) DPoP key was regenerated across a restart while the persisted
// refresh token is still bound to the old key (RFC 9449 §5) — refreshing can never succeed.
tokenResponse = () => Response.json({ error: 'invalid_dpop_proof', error_description: 'key mismatch' }, { status: 400 });
await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp' })).resolves.toBe('REDIRECT');
expect(provider.invalidateCredentials).toHaveBeenCalledWith('tokens');
expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1);
});
});
describe('extractWWWAuthenticateParams — DPoP scheme', () => {
it('extracts resource_metadata from a DPoP challenge (not just Bearer)', () => {
const response = new Response(null, {
headers: {
'WWW-Authenticate':
'DPoP error="invalid_token", resource_metadata="https://example.com/.well-known/oauth-protected-resource"'
}
});
const { resourceMetadataUrl, error } = extractWWWAuthenticateParams(response);
expect(resourceMetadataUrl?.toString()).toBe('https://example.com/.well-known/oauth-protected-resource');
expect(error).toBe('invalid_token');
});
it('extracts scope from a DPoP insufficient_scope challenge (SEP-2350 step-up for DPoP resources)', () => {
const response = new Response(null, {
headers: { 'WWW-Authenticate': 'DPoP error="insufficient_scope", scope="admin"' }
});
expect(extractWWWAuthenticateParams(response).scope).toBe('admin');
});
it('still returns {} for an unrecognized scheme', () => {
const response = new Response(null, { headers: { 'WWW-Authenticate': 'Digest realm="x"' } });
expect(extractWWWAuthenticateParams(response)).toEqual({});
});
});

View file

@ -0,0 +1,195 @@
import { accessTokenHash, DPOP_SUPPORTED_ALGS, DpopSession, generateDpopKeyPair, isDpopNonceChallenge } from '../../src/client/dpop';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
describe('generateDpopKeyPair', () => {
it('defaults to ES256 and produces a matching thumbprint', async () => {
const kp = await generateDpopKeyPair();
expect(kp.alg).toBe('ES256');
expect(kp.publicJwk.kty).toBe('EC');
expect(kp.publicJwk.crv).toBe('P-256');
expect(kp.publicJwk.d).toBeUndefined();
expect(typeof kp.thumbprint).toBe('string');
expect(kp.thumbprint.length).toBeGreaterThan(0);
});
it('generates a non-extractable private key by default', async () => {
const kp = await generateDpopKeyPair();
expect(kp.privateKey.extractable).toBe(false);
});
it('honors extractable: true for callers that need to export the key', async () => {
const kp = await generateDpopKeyPair({ extractable: true });
expect(kp.privateKey.extractable).toBe(true);
});
it('produces distinct key pairs (and thumbprints) across calls', async () => {
const a = await generateDpopKeyPair();
const b = await generateDpopKeyPair();
expect(a.thumbprint).not.toBe(b.thumbprint);
});
});
describe('accessTokenHash', () => {
it('is the base64url SHA-256 digest of the token (RFC 9449 §4.1)', async () => {
const hash = await accessTokenHash('some-access-token');
// Independently computed expected digest (Node's webcrypto, not this module's code path).
const expected = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode('some-access-token'))).toString(
'base64url'
);
expect(hash).toBe(expected);
});
it('is base64url, not base64 (no +, /, or = padding)', async () => {
const hash = await accessTokenHash('token-that-might-produce-padding-or-special-chars');
expect(hash).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe('DpopSession.buildProof', () => {
it('produces a well-formed dpop+jwt proof with the expected header shape', async () => {
const session = await DpopSession.create();
const proof = await session.buildProof({ htm: 'post', htu: 'https://mcp.example.com/mcp' });
const [headerPart, payloadPart, signaturePart] = proof.split('.');
expect(headerPart).toBeDefined();
expect(payloadPart).toBeDefined();
expect(signaturePart!.length).toBeGreaterThan(0);
const header = decodeJwtPart(headerPart!);
expect(header.typ).toBe('dpop+jwt');
expect(header.alg).toBe('ES256');
expect(header.jwk).toMatchObject({ kty: 'EC', crv: 'P-256' });
expect((header.jwk as Record<string, unknown>).d).toBeUndefined();
});
it('normalizes htm to upper-case and strips query/fragment from htu', async () => {
const session = await DpopSession.create();
const proof = await session.buildProof({ htm: 'post', htu: 'https://mcp.example.com/mcp?foo=bar#frag' });
const payload = decodeJwtPart(proof.split('.')[1]!);
expect(payload.htm).toBe('POST');
expect(payload.htu).toBe('https://mcp.example.com/mcp');
});
it('mints a fresh jti on every call — never caches or reuses a proof', async () => {
const session = await DpopSession.create();
const first = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
const second = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
expect(first).not.toBe(second);
const jti1 = decodeJwtPart(first.split('.')[1]!).jti;
const jti2 = decodeJwtPart(second.split('.')[1]!).jti;
expect(jti1).not.toBe(jti2);
});
it('includes ath only when an accessToken is provided', async () => {
const session = await DpopSession.create();
const proofWithToken = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp', accessToken: 'tok' });
const withToken = decodeJwtPart(proofWithToken.split('.')[1]!);
expect(withToken.ath).toBe(await accessTokenHash('tok'));
const proofWithoutToken = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
const withoutToken = decodeJwtPart(proofWithoutToken.split('.')[1]!);
expect(withoutToken.ath).toBeUndefined();
});
it('automatically carries the remembered nonce for a URL once one has been observed', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
session.rememberNonce(url, 'server-nonce-1');
const proof = await session.buildProof({ htm: 'POST', htu: url });
const payload = decodeJwtPart(proof.split('.')[1]!);
expect(payload.nonce).toBe('server-nonce-1');
});
it('an explicit nonce option overrides the remembered one', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
session.rememberNonce(url, 'remembered');
const proof = await session.buildProof({ htm: 'POST', htu: url, nonce: 'explicit' });
const payload = decodeJwtPart(proof.split('.')[1]!);
expect(payload.nonce).toBe('explicit');
});
it('keeps nonce state independent per origin', async () => {
const session = await DpopSession.create();
session.rememberNonce('https://as.example.com/token', 'as-nonce');
session.rememberNonce('https://rs.example.com/mcp', 'rs-nonce');
expect(session.nonceFor('https://as.example.com/token')).toBe('as-nonce');
expect(session.nonceFor('https://rs.example.com/mcp')).toBe('rs-nonce');
expect(session.nonceFor('https://other.example.com/x')).toBeUndefined();
});
it('supports every DPOP_SUPPORTED_ALGS entry as a construction option', async () => {
for (const alg of DPOP_SUPPORTED_ALGS) {
const session = await DpopSession.create({ alg });
expect(session.alg).toBe(alg);
const proof = await session.buildProof({ htm: 'GET', htu: 'https://mcp.example.com/mcp' });
expect(decodeJwtPart(proof.split('.')[0]!).alg).toBe(alg);
}
});
it('reuses a caller-supplied key pair instead of generating a new one', async () => {
const kp = await generateDpopKeyPair();
const session = await DpopSession.create({ keyPair: kp });
expect(session.thumbprint).toBe(kp.thumbprint);
});
});
describe('DpopSession.observeNonce', () => {
it('remembers a DPoP-Nonce response header for the given url', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
const response = new Response(null, { headers: { 'DPoP-Nonce': 'fresh-nonce' } });
session.observeNonce(response, url);
expect(session.nonceFor(url)).toBe('fresh-nonce');
});
it('is a no-op when the response carries no DPoP-Nonce header', async () => {
const session = await DpopSession.create();
const url = 'https://mcp.example.com/mcp';
session.rememberNonce(url, 'existing');
session.observeNonce(new Response(null), url);
expect(session.nonceFor(url)).toBe('existing');
});
});
describe('isDpopNonceChallenge', () => {
it('is true for a 401 with a DPoP use_dpop_nonce challenge', () => {
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce", resource_metadata="https://example.com/prm"' }
});
expect(isDpopNonceChallenge(response)).toBe(true);
});
it('recognizes a DPoP challenge listed alongside other schemes', () => {
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'Bearer error="invalid_token", DPoP error="use_dpop_nonce"' }
});
expect(isDpopNonceChallenge(response)).toBe(true);
});
it('is false for a plain invalid_dpop_proof challenge (not a nonce challenge)', () => {
const response = new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="invalid_dpop_proof"' }
});
expect(isDpopNonceChallenge(response)).toBe(false);
});
it('is false for a non-401 status', () => {
const response = new Response(null, { status: 400, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } });
expect(isDpopNonceChallenge(response)).toBe(false);
});
it('is false when there is no WWW-Authenticate header at all', () => {
expect(isDpopNonceChallenge(new Response(null, { status: 401 }))).toBe(false);
});
it('is false for a Bearer-only challenge', () => {
const response = new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'Bearer error="use_dpop_nonce"' } });
expect(isDpopNonceChallenge(response)).toBe(false);
});
});

View file

@ -0,0 +1,346 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import type { Mock, Mocked, MockedFunction } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OAuthClientProvider } from '../../src/client/auth';
import { DpopSession } from '../../src/client/dpop';
// `auth` is mocked (not exercised for real — it would attempt real network discovery) so the
// "credential retry happens before a nonce challenge is discovered" test below can simulate a
// successful re-authorization without a real OAuth flow. Everything else — including
// `withDpopFromProvider`, which is what makes `withOAuth` DPoP-aware — stays the real implementation.
vi.mock('../../src/client/auth', async () => {
const actual = await vi.importActual<typeof import('../../src/client/auth')>('../../src/client/auth');
return { ...actual, auth: vi.fn() };
});
import { auth } from '../../src/client/auth';
import { withDpop, withDpopFromProvider, withOAuth } from '../../src/client/middleware';
const mockAuth = auth as MockedFunction<typeof auth>;
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
describe('withDpop', () => {
let session: DpopSession;
let getToken: MockedFunction<() => string | undefined | Promise<string | undefined>>;
let mockFetch: MockedFunction<FetchLike>;
beforeEach(async () => {
session = await DpopSession.create();
getToken = vi.fn().mockResolvedValue('the-access-token');
mockFetch = vi.fn();
});
it('presents Authorization: DPoP <token> plus a fresh proof bound to the request', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
const [, init] = mockFetch.mock.calls[0]!;
const headers = init!.headers as Headers;
expect(headers.get('Authorization')).toBe('DPoP the-access-token');
const proof = headers.get('DPoP');
expect(proof).toBeTruthy();
const payload = decodeJwtPart(proof!.split('.')[1]!);
expect(payload.htm).toBe('POST');
expect(payload.htu).toBe('https://mcp.example.com/mcp');
expect(payload.ath).toBeDefined();
});
it('defaults to GET when no method is given', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp');
const payload = decodeJwtPart(((mockFetch.mock.calls[0]![1]!.headers as Headers).get('DPoP') as string).split('.')[1]!);
expect(payload.htm).toBe('GET');
});
it('sends no Authorization/DPoP headers when getToken resolves to undefined', async () => {
getToken.mockResolvedValue(undefined);
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { headers: { Accept: 'application/json' } });
const headers = new Headers(mockFetch.mock.calls[0]![1]!.headers);
expect(headers.has('Authorization')).toBe(false);
expect(headers.has('DPoP')).toBe(false);
expect(headers.get('Accept')).toBe('application/json');
});
it('retries once, with a fresh proof carrying the nonce, on a use_dpop_nonce challenge', async () => {
mockFetch
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstProof = mockFetch.mock.calls[0]![1]!.headers as Headers;
const secondProof = mockFetch.mock.calls[1]![1]!.headers as Headers;
expect(secondProof.get('DPoP')).not.toBe(firstProof.get('DPoP')); // fresh jti, never resent
expect(decodeJwtPart((secondProof.get('DPoP') as string).split('.')[1]!).nonce).toBe('rs-nonce-1');
});
it('carries a DPoP-Nonce received on a 2xx response in the next request’s proof (RFC 9449 §8.2)', async () => {
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'DPoP-Nonce': 'rs-nonce-1' } }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondProof = (mockFetch.mock.calls[1]![1]!.headers as Headers).get('DPoP') as string;
expect(decodeJwtPart(secondProof.split('.')[1]!).nonce).toBe('rs-nonce-1');
});
it('does not retry on an ordinary (non-nonce) 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp');
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does not retry a use_dpop_nonce challenge that carries no fresh DPoP-Nonce (nothing new to retry with)', async () => {
session.rememberNonce('https://mcp.example.com/mcp', 'stale-nonce');
mockFetch.mockResolvedValue(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } }));
const enhancedFetch = withDpop(session, getToken)(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp');
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
describe('withDpop — session/token sources', () => {
it('passes the request through untouched when the session source resolves to undefined', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } };
const noSession = vi.fn<() => Promise<DpopSession | undefined>>().mockResolvedValue(undefined);
await withDpop(noSession, async () => 'tok')(mockFetch)('https://mcp.example.com/mcp', init);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', init);
});
it('leaves an existing Authorization header alone when getToken resolves to undefined', async () => {
const session = await DpopSession.create();
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
const init = { method: 'POST', headers: { Authorization: 'Bearer tok' } };
const noToken = vi.fn<() => Promise<string | undefined>>().mockResolvedValue(undefined);
await withDpop(session, noToken)(mockFetch)('https://mcp.example.com/mcp', init);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', init);
});
it('accepts a lazily-resolved session', async () => {
const session = await DpopSession.create();
const mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
await withDpop(
async () => session,
async () => 'tok'
)(mockFetch)('https://mcp.example.com/mcp', { method: 'POST' });
expect((mockFetch.mock.calls[0]![1]!.headers as Headers).get('Authorization')).toBe('DPoP tok');
});
});
describe('withDpopFromProvider', () => {
let session: DpopSession;
let provider: Mocked<OAuthClientProvider>;
let mockFetch: MockedFunction<FetchLike>;
const bearerInit = { method: 'POST', headers: { Authorization: 'Bearer tok-1' } };
beforeEach(async () => {
session = await DpopSession.create();
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
saveTokens: vi.fn(),
clientInformation: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
dpop: vi.fn().mockResolvedValue(session)
};
mockFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 200 }));
});
it('upgrades a token_type=DPoP token to the DPoP scheme with a proof bound to the request', async () => {
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp?x=1', bearerInit);
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
expect(headers.get('Authorization')).toBe('DPoP tok-1');
const payload = decodeJwtPart(headers.get('DPoP')!.split('.')[1]!);
expect(payload).toMatchObject({ htm: 'POST', htu: 'https://mcp.example.com/mcp' });
expect(payload.ath).toBeDefined();
});
it('leaves a token_type=Bearer token on the Bearer scheme with no proof, even though dpop() resolves (RFC 9449 §7.1)', async () => {
// An AS that ignored the DPoP proof (or does not support DPoP) issues token_type=Bearer;
// presenting that with the DPoP scheme to a Bearer-only resource server is a guaranteed 401.
provider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', bearerInit);
});
it('is a pass-through when dpop() resolves to undefined', async () => {
(provider.dpop as Mock).mockResolvedValue(undefined);
await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
expect(mockFetch).toHaveBeenCalledWith('https://mcp.example.com/mcp', bearerInit);
});
it('retries once with the server nonce on a use_dpop_nonce challenge, and not when the challenge carries no fresh DPoP-Nonce', async () => {
mockFetch
.mockResolvedValueOnce(
new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'n1' } })
)
.mockResolvedValueOnce(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"' } }));
const response = await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit);
expect(response.status).toBe(401);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(decodeJwtPart((mockFetch.mock.calls[1]![1]!.headers as Headers).get('DPoP')!.split('.')[1]!).nonce).toBe('n1');
});
it('stamps a throwing tokens()/dpop() as an auth-seam failure (not a network error)', async () => {
const { isAuthSeamEscape } = await import('../../src/client/authSeam');
provider.tokens.mockRejectedValue(new Error('storage unavailable'));
const error = await withDpopFromProvider(provider)(mockFetch)('https://mcp.example.com/mcp', bearerInit).catch(error_ => error_);
expect(isAuthSeamEscape(error)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('withOAuth — DPoP-aware', () => {
let mockProvider: Mocked<OAuthClientProvider>;
let mockFetch: MockedFunction<FetchLike>;
let session: DpopSession;
beforeEach(async () => {
session = await DpopSession.create();
mockProvider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
tokens: vi.fn(),
saveTokens: vi.fn(),
clientInformation: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
invalidateCredentials: vi.fn(),
dpop: vi.fn().mockResolvedValue(session)
};
mockFetch = vi.fn();
});
it('presents DPoP Authorization + proof (not Bearer) when provider.dpop() resolves to a session', async () => {
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
expect(headers.get('Authorization')).toBe('DPoP tok-1');
expect(headers.get('DPoP')).toBeTruthy();
});
it('keeps presenting plain Bearer when the provider has no dpop() (existing behavior unaffected)', async () => {
mockProvider.dpop = undefined;
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
await enhancedFetch('https://mcp.example.com/mcp');
const headers = mockFetch.mock.calls[0]![1]!.headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer tok-1');
expect(headers.has('DPoP')).toBe(false);
});
it('retries once on a use_dpop_nonce challenge without invoking the auth() re-authorization flow', async () => {
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
mockFetch
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(2);
// The nonce leg is a self-contained retry — it never calls provider.saveTokens or any
// other re-authorization side effect.
expect(mockProvider.saveTokens).not.toHaveBeenCalled();
});
it('completes credential re-authorization first, then a nonce challenge discovered on the retried request (auth/dpop-nonce shape)', async () => {
// No token yet -> the first 401 carries no DPoP-Nonce header at all, so it is NOT a nonce
// challenge; only after auth() succeeds and a real token is presented does the resource
// server reveal its nonce requirement. Regression test for a bug where the nonce check
// only ran *before* the credential retry, never after it — real conformance servers
// (auth/dpop-nonce) hit exactly this ordering.
mockProvider.tokens.mockResolvedValue(undefined);
mockAuth.mockImplementation(async () => {
mockProvider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' });
return 'AUTHORIZED';
});
mockFetch
.mockResolvedValueOnce(new Response(null, { status: 401 })) // no token, no nonce header
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }
})
)
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const enhancedFetch = withOAuth(mockProvider, 'https://mcp.example.com')(mockFetch);
const response = await enhancedFetch('https://mcp.example.com/mcp', { method: 'POST' });
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(3);
expect(mockAuth).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,153 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
import { DpopSession } from '../../src/client/dpop';
import { SSEClientTransport } from '../../src/client/sse';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
interface Seen {
method: string;
path: string;
authorization?: string;
proof?: Record<string, unknown>;
}
/**
* Drives the transport against a real HTTP+SSE server with a real {@linkcode DpopSession} behind an
* {@linkcode OAuthClientProvider}, asserting on what the server receives. The announced message
* endpoint is deliberately a different path from the SSE URL (the normal shape for this transport)
* so the POST proof's `htu` binding is actually exercised.
*/
describe('SSEClientTransport — DPoP', () => {
let server: Server;
let baseUrl: URL;
let session: DpopSession;
let provider: OAuthClientProvider;
let transport: SSEClientTransport;
let seen: Seen[];
let postHandler: (req: IncomingMessage, res: ServerResponse) => void;
beforeEach(async () => {
seen = [];
postHandler = (_req, res) => res.writeHead(202).end();
server = createServer((req, res) => {
const proofHeader = req.headers.dpop as string | undefined;
seen.push({
method: req.method!,
path: req.url!.split('?')[0]!,
authorization: req.headers.authorization,
proof: proofHeader ? decodeJwtPart(proofHeader.split('.')[1]!) : undefined
});
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
});
res.write('event: endpoint\n');
res.write(`data: ${baseUrl.origin}/messages?sessionId=s1\n\n`);
return;
}
req.resume().on('end', () => postHandler(req, res));
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => {
baseUrl = new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}/sse`);
resolve();
});
});
session = await DpopSession.create();
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn(),
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
dpop: () => session
};
transport = new SSEClientTransport(baseUrl, { authProvider: provider });
await transport.start();
});
afterEach(async () => {
await transport.close().catch(() => {});
await new Promise<void>(resolve => server.close(() => resolve()));
});
it('presents DPoP Authorization + a proof bound to GET and the SSE URL on the event stream', () => {
expect(seen[0]).toMatchObject({ method: 'GET', authorization: 'DPoP tok-1', proof: { htm: 'GET', htu: baseUrl.href } });
});
it('presents DPoP Authorization + a proof on POST, bound to the announced message endpoint (not the SSE URL)', async () => {
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
// RFC 9449 §4.2: htu is the URI of *this* request, minus query — the /messages endpoint.
expect(seen[1]).toMatchObject({
method: 'POST',
path: '/messages',
authorization: 'DPoP tok-1',
proof: { htm: 'POST', htu: `${baseUrl.origin}/messages` }
});
});
it('retries the POST once on a use_dpop_nonce challenge, with a fresh proof carrying the nonce, without re-authorizing', async () => {
const onUnauthorized = vi.spyOn((transport as unknown as { _authProvider: AuthProvider })._authProvider, 'onUnauthorized');
let postCalls = 0;
postHandler = (_req, res) => {
postCalls++;
if (postCalls === 1) {
res.writeHead(401, { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'rs-nonce-1' }).end();
return;
}
res.writeHead(202).end();
};
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
const posts = seen.filter(s => s.method === 'POST');
expect(posts).toHaveLength(2);
expect(posts[1]!.proof).toMatchObject({ htu: `${baseUrl.origin}/messages`, nonce: 'rs-nonce-1' });
expect(posts[1]!.proof!.jti).not.toBe(posts[0]!.proof!.jti);
expect(onUnauthorized).not.toHaveBeenCalled();
});
it('wraps a caller-supplied eventSourceInit.fetch too (it still runs, and the stream request is DPoP-signed)', async () => {
await transport.close();
const esFetch = vi.fn<FetchLike>((url, init) => fetch(url, init));
const t = new SSEClientTransport(baseUrl, { authProvider: provider, eventSourceInit: { fetch: esFetch as typeof fetch } });
await t.start();
expect(esFetch).toHaveBeenCalledTimes(1);
expect(new Headers(esFetch.mock.calls[0]![1]!.headers).get('Authorization')).toBe('DPoP tok-1');
expect(seen.at(-1)).toMatchObject({ method: 'GET', proof: { htm: 'GET' } });
await t.close();
});
it('leaves a plain AuthProvider untouched (Bearer via token())', async () => {
await transport.close();
const bearer = new SSEClientTransport(baseUrl, { authProvider: { token: async () => 'bearer-tok' } });
await bearer.start();
await bearer.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(seen.at(-1)).toMatchObject({ method: 'POST', authorization: 'Bearer bearer-tok', proof: undefined });
await bearer.close();
});
});

View file

@ -0,0 +1,206 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
import { DpopSession } from '../../src/client/dpop';
import { withDpop } from '../../src/client/middleware';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
function decodeJwtPart(part: string): Record<string, unknown> {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
}
function proofOf(call: unknown[]): Record<string, unknown> {
return decodeJwtPart(new Headers((call[1] as RequestInit).headers).get('DPoP')!.split('.')[1]!);
}
const nonceChallenge = (nonce?: string) =>
new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', ...(nonce ? { 'DPoP-Nonce': nonce } : {}) }
});
/**
* These tests drive the transport with a real {@linkcode DpopSession} behind an
* {@linkcode OAuthClientProvider} and assert on what reaches `fetch` — the transport applies DPoP by
* wrapping its resource-server fetch with `withDpopFromProvider`, so the wire is the contract.
*/
describe('StreamableHTTPClientTransport — DPoP', () => {
const url = new URL('http://localhost:1234/mcp');
let session: DpopSession;
let provider: OAuthClientProvider & { tokens: Mock };
let transport: StreamableHTTPClientTransport;
let fetchSpy: Mock<typeof fetch>;
const authProviderOf = (t: StreamableHTTPClientTransport) => (t as unknown as { _authProvider: AuthProvider })._authProvider;
beforeEach(async () => {
session = await DpopSession.create();
provider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata() {
return { redirect_uris: ['http://localhost/callback'] };
},
clientInformation: vi.fn(),
tokens: vi.fn().mockResolvedValue({ access_token: 'tok-1', token_type: 'DPoP' }),
saveTokens: vi.fn(),
redirectToAuthorization: vi.fn(),
saveCodeVerifier: vi.fn(),
codeVerifier: vi.fn(),
dpop: () => session
};
fetchSpy = vi.spyOn(globalThis, 'fetch');
transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
});
afterEach(async () => {
await transport.close().catch(() => {});
vi.restoreAllMocks();
});
it('presents Authorization: DPoP + a proof bound to POST and the MCP URL', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
const headers = new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers);
expect(headers.get('Authorization')).toBe('DPoP tok-1');
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'POST', htu: url.href });
expect(proofOf(fetchSpy.mock.calls[0]!).ath).toBeDefined();
});
it('binds the GET SSE stream proof to GET', async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 405 }));
await (transport as unknown as { _startOrAuthSse: (o: object) => Promise<void> })._startOrAuthSse({});
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'GET', htu: url.href });
});
it('binds the session-termination proof to DELETE, and retries DELETE once on a use_dpop_nonce challenge', async () => {
(transport as unknown as { _sessionId?: string })._sessionId = 'sess-1';
fetchSpy.mockResolvedValueOnce(nonceChallenge('rs-nonce-1')).mockResolvedValueOnce(new Response(null, { status: 200 }));
await expect(transport.terminateSession()).resolves.toBeUndefined();
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(proofOf(fetchSpy.mock.calls[0]!)).toMatchObject({ htm: 'DELETE', htu: url.href });
expect(proofOf(fetchSpy.mock.calls[1]!)).toMatchObject({ htm: 'DELETE', nonce: 'rs-nonce-1' });
});
it('presents a token_type=Bearer token with the Bearer scheme and no proof, even though dpop() resolves (RFC 9449 §7.1)', async () => {
provider.tokens.mockResolvedValue({ access_token: 'tok-1', token_type: 'Bearer' });
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
const headers = new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers);
expect(headers.get('Authorization')).toBe('Bearer tok-1');
expect(headers.has('DPoP')).toBe(false);
});
it("a caller-supplied per-request 'dpop' header cannot override the transport's own proof (reserved header name)", async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send(
{ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' },
{ headers: { dpop: 'attacker-supplied-proof', DPoP: 'attacker-supplied-proof-2' } }
);
expect(proofOf(fetchSpy.mock.calls[0]!).htm).toBe('POST');
});
it('retries once on a use_dpop_nonce challenge with a fresh proof carrying the nonce, without re-authorizing', async () => {
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized');
fetchSpy.mockResolvedValueOnce(nonceChallenge('rs-nonce-1')).mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(fetchSpy).toHaveBeenCalledTimes(2);
const [first, second] = fetchSpy.mock.calls.map(c => proofOf(c));
expect(second!.nonce).toBe('rs-nonce-1');
expect(second!.jti).not.toBe(first!.jti); // RFC 9449 §4.2: never replay the original proof
expect(onUnauthorized).not.toHaveBeenCalled();
});
it('does not spend the nonce retry on a use_dpop_nonce challenge that carries no fresh DPoP-Nonce', async () => {
session.rememberNonce(url, 'stale-nonce');
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
fetchSpy.mockResolvedValueOnce(nonceChallenge()).mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
// Straight to the credential path: challenge → onUnauthorized → one retry.
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
});
it('bounds retries when the nonce challenge never clears: a nonce retry per attempt, one credential retry, then throws', async () => {
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
fetchSpy.mockImplementation(async () => nonceChallenge('rs-nonce-1'));
await expect(transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' })).rejects.toThrow();
// attempt 1 (+ its nonce retry) → onUnauthorized → attempt 2 (+ its nonce retry) → give up.
expect(fetchSpy).toHaveBeenCalledTimes(4);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
});
it('handles a credential 401 first and a nonce challenge on the re-authorized retry (auth/dpop-nonce shape)', async () => {
const onUnauthorized = vi.spyOn(authProviderOf(transport), 'onUnauthorized').mockResolvedValue();
fetchSpy
.mockResolvedValueOnce(new Response(null, { status: 401, headers: { 'WWW-Authenticate': 'DPoP error="invalid_token"' } }))
.mockResolvedValueOnce(nonceChallenge('rs-nonce-1'))
.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(onUnauthorized).toHaveBeenCalledTimes(1);
expect(proofOf(fetchSpy.mock.calls[2]!).nonce).toBe('rs-nonce-1');
});
it('carries a DPoP-Nonce received on a 2xx response into the next request’s proof (RFC 9449 §8.2)', async () => {
fetchSpy
.mockResolvedValueOnce(new Response(null, { status: 202, headers: { 'DPoP-Nonce': 'rs-nonce-1' } }))
.mockResolvedValueOnce(new Response(null, { status: 202 }));
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-2' });
expect(proofOf(fetchSpy.mock.calls[1]!).nonce).toBe('rs-nonce-1');
});
it('wraps a caller-supplied fetch (it still runs, and sees the DPoP headers)', async () => {
const customFetch = vi.fn<FetchLike>().mockResolvedValue(new Response(null, { status: 202 }));
const t = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: customFetch });
await t.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(fetchSpy).not.toHaveBeenCalled();
expect(new Headers(customFetch.mock.calls[0]![1]!.headers).get('Authorization')).toBe('DPoP tok-1');
await t.close();
});
it('leaves a plain AuthProvider untouched (Bearer via token()); DPoP there is opt-in via fetch: withDpop(...)', async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 202 }));
const bearer = new StreamableHTTPClientTransport(url, { authProvider: { token: async () => 'bearer-tok' } });
await bearer.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-1' });
expect(new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers).get('Authorization')).toBe('Bearer bearer-tok');
expect(new Headers((fetchSpy.mock.calls[0]![1] as RequestInit).headers).has('DPoP')).toBe(false);
await bearer.close();
const explicit = new StreamableHTTPClientTransport(url, {
authProvider: { token: async () => 'pop-tok' },
fetch: withDpop(session, () => 'pop-tok')(fetch)
});
await explicit.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'id-2' });
expect(new Headers((fetchSpy.mock.calls[1]![1] as RequestInit).headers).get('Authorization')).toBe('DPoP pop-tok');
expect(proofOf(fetchSpy.mock.calls[1]!)).toMatchObject({ htm: 'POST', htu: url.href });
await explicit.close();
});
});