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
5 changed files with 1753 additions and 30 deletions
246
packages/client/test/client/auth.dpop.test.ts
Normal file
246
packages/client/test/client/auth.dpop.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
195
packages/client/test/client/dpop.test.ts
Normal file
195
packages/client/test/client/dpop.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
346
packages/client/test/client/middleware.dpop.test.ts
Normal file
346
packages/client/test/client/middleware.dpop.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
packages/client/test/client/sse.dpop.test.ts
Normal file
153
packages/client/test/client/sse.dpop.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
206
packages/client/test/client/streamableHttp.dpop.test.ts
Normal file
206
packages/client/test/client/streamableHttp.dpop.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue