Skip to content

Commit 4a2ba8a

Browse files
authored
feat: add PKCE conformance tests (#119)
* feat: add PKCE conformance tests Adds conformance checks for PKCE requirements from the MCP auth spec: - pkce-code-challenge-sent: Verifies client sends code_challenge in auth request - pkce-s256-method-used: Verifies client uses S256 code challenge method - pkce-code-verifier-sent: Verifies client sends code_verifier in token request - pkce-verifier-matches-challenge: Validates BASE64URL(SHA256(verifier)) = challenge Also adds auth/pkce-no-s256-support scenario that tests clients correctly refuse when S256 is not in code_challenge_methods_supported. All PKCE checks are added to the createAuthServer helper, so they automatically apply to all existing auth scenarios. Fixes #77 * feat: add bad client example that skips PKCE Adds auth-test-no-pkce.ts which implements a custom OAuth flow that deliberately skips PKCE (no code_challenge in auth request, no code_verifier in token request). Also adds negative test to verify the conformance suite correctly detects this non-compliant behavior. * chore: skip pkce-no-s256-support scenario for now * chore: fix formatting * refactor: remove pkce-no-s256-support scenario and always validate PKCE - Remove pkce-no-s256-support scenario (was skipped anyway) - Make pkce-verifier-matches-challenge fail if either code_challenge or code_verifier is missing, rather than only running when both present - Update negative test to expect the new failure
1 parent 2d52de5 commit 4a2ba8a

4 files changed

Lines changed: 303 additions & 1 deletion

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Broken client that doesn't use PKCE.
5+
*
6+
* BUG: Skips PKCE entirely - doesn't send code_challenge in authorization
7+
* request and doesn't send code_verifier in token request.
8+
*
9+
* Per MCP spec: "MCP clients MUST implement PKCE according to OAuth 2.1"
10+
*/
11+
12+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
13+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
14+
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js';
15+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
16+
import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js';
17+
import { runAsCli } from './helpers/cliRunner';
18+
import { logger } from './helpers/logger';
19+
20+
interface OAuthTokens {
21+
access_token: string;
22+
token_type: string;
23+
expires_in?: number;
24+
refresh_token?: string;
25+
scope?: string;
26+
}
27+
28+
/**
29+
* Custom OAuth flow that deliberately skips PKCE.
30+
* This is intentionally broken behavior for conformance testing.
31+
*/
32+
async function oauthFlowWithoutPkce(
33+
_serverUrl: string | URL,
34+
resourceMetadataUrl: string | URL,
35+
fetchFn: FetchLike
36+
): Promise<OAuthTokens> {
37+
// 1. Fetch Protected Resource Metadata
38+
const prmResponse = await fetchFn(resourceMetadataUrl);
39+
if (!prmResponse.ok) {
40+
throw new Error(`Failed to fetch PRM: ${prmResponse.status}`);
41+
}
42+
const prm = await prmResponse.json();
43+
const authServerUrl = prm.authorization_servers?.[0];
44+
if (!authServerUrl) {
45+
throw new Error('No authorization server in PRM');
46+
}
47+
48+
// 2. Fetch Authorization Server Metadata
49+
const asMetadataUrl = new URL(
50+
'/.well-known/oauth-authorization-server',
51+
authServerUrl
52+
);
53+
const asResponse = await fetchFn(asMetadataUrl.toString());
54+
if (!asResponse.ok) {
55+
throw new Error(`Failed to fetch AS metadata: ${asResponse.status}`);
56+
}
57+
const asMetadata = await asResponse.json();
58+
59+
// 3. Register client (DCR)
60+
const dcrResponse = await fetchFn(asMetadata.registration_endpoint, {
61+
method: 'POST',
62+
headers: { 'Content-Type': 'application/json' },
63+
body: JSON.stringify({
64+
client_name: 'test-auth-client-no-pkce',
65+
redirect_uris: ['http://localhost:3000/callback']
66+
})
67+
});
68+
if (!dcrResponse.ok) {
69+
throw new Error(`DCR failed: ${dcrResponse.status}`);
70+
}
71+
const clientInfo = await dcrResponse.json();
72+
73+
// 4. Build authorization URL WITHOUT PKCE (BUG!)
74+
const authUrl = new URL(asMetadata.authorization_endpoint);
75+
authUrl.searchParams.set('response_type', 'code');
76+
authUrl.searchParams.set('client_id', clientInfo.client_id);
77+
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
78+
authUrl.searchParams.set('state', 'test-state');
79+
// BUG: NOT setting code_challenge or code_challenge_method
80+
81+
// 5. Fetch authorization endpoint (simulates redirect)
82+
const authResponse = await fetchFn(authUrl.toString(), {
83+
redirect: 'manual'
84+
});
85+
const location = authResponse.headers.get('location');
86+
if (!location) {
87+
throw new Error('No redirect from authorization endpoint');
88+
}
89+
const redirectUrl = new URL(location);
90+
const authCode = redirectUrl.searchParams.get('code');
91+
if (!authCode) {
92+
throw new Error('No auth code in redirect');
93+
}
94+
95+
// 6. Exchange code for token WITHOUT code_verifier (BUG!)
96+
const tokenResponse = await fetchFn(asMetadata.token_endpoint, {
97+
method: 'POST',
98+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
99+
body: new URLSearchParams({
100+
grant_type: 'authorization_code',
101+
code: authCode,
102+
redirect_uri: 'http://localhost:3000/callback',
103+
client_id: clientInfo.client_id
104+
// BUG: NOT sending code_verifier
105+
}).toString()
106+
});
107+
108+
if (!tokenResponse.ok) {
109+
const error = await tokenResponse.text();
110+
throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`);
111+
}
112+
113+
return tokenResponse.json();
114+
}
115+
116+
/**
117+
* Creates a fetch wrapper that uses OAuth without PKCE.
118+
*/
119+
function withOAuthNoPkce(baseUrl: string | URL): Middleware {
120+
let tokens: OAuthTokens | undefined;
121+
122+
return (next: FetchLike) => {
123+
return async (
124+
input: string | URL,
125+
init?: RequestInit
126+
): Promise<Response> => {
127+
const makeRequest = async (): Promise<Response> => {
128+
const headers = new Headers(init?.headers);
129+
if (tokens) {
130+
headers.set('Authorization', `Bearer ${tokens.access_token}`);
131+
}
132+
return next(input, { ...init, headers });
133+
};
134+
135+
let response = await makeRequest();
136+
137+
if (response.status === 401) {
138+
const { resourceMetadataUrl } = extractWWWAuthenticateParams(response);
139+
if (!resourceMetadataUrl) {
140+
throw new Error('No resource_metadata in WWW-Authenticate');
141+
}
142+
tokens = await oauthFlowWithoutPkce(baseUrl, resourceMetadataUrl, next);
143+
response = await makeRequest();
144+
}
145+
146+
return response;
147+
};
148+
};
149+
}
150+
151+
export async function runClient(serverUrl: string): Promise<void> {
152+
const client = new Client(
153+
{ name: 'test-auth-client-no-pkce', version: '1.0.0' },
154+
{ capabilities: {} }
155+
);
156+
157+
const oauthFetch = withOAuthNoPkce(new URL(serverUrl))(fetch);
158+
159+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
160+
fetch: oauthFetch
161+
});
162+
163+
await client.connect(transport);
164+
logger.debug('Successfully connected to MCP server');
165+
166+
await client.listTools();
167+
logger.debug('Successfully listed tools');
168+
169+
await transport.close();
170+
logger.debug('Connection closed successfully');
171+
}
172+
173+
runAsCli(runClient, import.meta.url, 'auth-test-no-pkce <server-url>');

src/scenarios/client/auth/helpers/createAuthServer.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import express, { Request, Response } from 'express';
2+
import { createHash } from 'crypto';
23
import type { ConformanceCheck } from '../../../../types';
34
import { createRequestLogger } from '../../../request-logger';
45
import { SpecReferences } from '../spec-references';
56
import { MockTokenVerifier } from './mockTokenVerifier';
67

8+
/**
9+
* Compute S256 code challenge from a code verifier.
10+
* BASE64URL(SHA256(code_verifier))
11+
*/
12+
function computeS256Challenge(codeVerifier: string): string {
13+
const hash = createHash('sha256').update(codeVerifier).digest();
14+
return hash
15+
.toString('base64')
16+
.replace(/\+/g, '-')
17+
.replace(/\//g, '_')
18+
.replace(/=+$/, '');
19+
}
20+
721
export interface TokenRequestResult {
822
token: string;
923
scopes: string[];
@@ -27,6 +41,8 @@ export interface AuthServerOptions {
2741
clientIdMetadataDocumentSupported?: boolean;
2842
/** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */
2943
disableDynamicRegistration?: boolean;
44+
/** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */
45+
codeChallengeMethodsSupported?: string[] | null;
3046
tokenVerifier?: MockTokenVerifier;
3147
onTokenRequest?: (requestData: {
3248
scope?: string;
@@ -68,6 +84,7 @@ export function createAuthServer(
6884
tokenEndpointAuthSigningAlgValuesSupported,
6985
clientIdMetadataDocumentSupported,
7086
disableDynamicRegistration = false,
87+
codeChallengeMethodsSupported = ['S256'],
7188
tokenVerifier,
7289
onTokenRequest,
7390
onAuthorizationRequest,
@@ -76,6 +93,8 @@ export function createAuthServer(
7693

7794
// Track scopes from the most recent authorization request
7895
let lastAuthorizationScopes: string[] = [];
96+
// Track PKCE code_challenge for verification in token request
97+
let storedCodeChallenge: string | undefined;
7998

8099
const authRoutes = {
81100
authorization_endpoint: `${routePrefix}/authorize`,
@@ -122,7 +141,10 @@ export function createAuthServer(
122141
}),
123142
response_types_supported: ['code'],
124143
grant_types_supported: grantTypesSupported,
125-
code_challenge_methods_supported: ['S256'],
144+
// PKCE support - null means omit from metadata (for negative testing)
145+
...(codeChallengeMethodsSupported !== null && {
146+
code_challenge_methods_supported: codeChallengeMethodsSupported
147+
}),
126148
token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported,
127149
...(tokenEndpointAuthSigningAlgValuesSupported && {
128150
token_endpoint_auth_signing_alg_values_supported:
@@ -165,6 +187,41 @@ export function createAuthServer(
165187
}
166188
});
167189

190+
// PKCE: Store code_challenge for later verification
191+
const codeChallenge = req.query.code_challenge as string | undefined;
192+
const codeChallengeMethod = req.query.code_challenge_method as
193+
| string
194+
| undefined;
195+
storedCodeChallenge = codeChallenge;
196+
197+
// PKCE: Check code_challenge is present
198+
checks.push({
199+
id: 'pkce-code-challenge-sent',
200+
name: 'PKCE Code Challenge',
201+
description: codeChallenge
202+
? 'Client sent code_challenge in authorization request'
203+
: 'Client MUST send code_challenge in authorization request',
204+
status: codeChallenge ? 'SUCCESS' : 'FAILURE',
205+
timestamp,
206+
specReferences: [SpecReferences.MCP_PKCE]
207+
});
208+
209+
// PKCE: Check S256 method is used
210+
checks.push({
211+
id: 'pkce-s256-method-used',
212+
name: 'PKCE S256 Method',
213+
description:
214+
codeChallengeMethod === 'S256'
215+
? 'Client used S256 code challenge method'
216+
: 'Client MUST use S256 code challenge method when technically capable',
217+
status: codeChallengeMethod === 'S256' ? 'SUCCESS' : 'FAILURE',
218+
timestamp,
219+
specReferences: [SpecReferences.MCP_PKCE],
220+
details: {
221+
method: codeChallengeMethod || 'not specified'
222+
}
223+
});
224+
168225
// Track scopes from authorization request for token issuance
169226
const scopeParam = req.query.scope as string | undefined;
170227
lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : [];
@@ -206,6 +263,61 @@ export function createAuthServer(
206263
}
207264
});
208265

266+
// PKCE: Check code_verifier is present (only for authorization_code grant)
267+
const codeVerifier = req.body.code_verifier as string | undefined;
268+
if (grantType === 'authorization_code') {
269+
checks.push({
270+
id: 'pkce-code-verifier-sent',
271+
name: 'PKCE Code Verifier',
272+
description: codeVerifier
273+
? 'Client sent code_verifier in token request'
274+
: 'Client MUST send code_verifier in token request',
275+
status: codeVerifier ? 'SUCCESS' : 'FAILURE',
276+
timestamp,
277+
specReferences: [SpecReferences.MCP_PKCE]
278+
});
279+
280+
// PKCE: Validate code_verifier matches code_challenge (S256)
281+
// Fail if either is missing
282+
const computedChallenge =
283+
codeVerifier && storedCodeChallenge
284+
? computeS256Challenge(codeVerifier)
285+
: undefined;
286+
const matches =
287+
computedChallenge !== undefined &&
288+
computedChallenge === storedCodeChallenge;
289+
290+
let description: string;
291+
if (!storedCodeChallenge && !codeVerifier) {
292+
description =
293+
'Neither code_challenge nor code_verifier were sent - PKCE is required';
294+
} else if (!storedCodeChallenge) {
295+
description =
296+
'code_challenge was not sent in authorization request - PKCE is required';
297+
} else if (!codeVerifier) {
298+
description =
299+
'code_verifier was not sent in token request - PKCE is required';
300+
} else if (matches) {
301+
description = 'code_verifier correctly matches code_challenge (S256)';
302+
} else {
303+
description = 'code_verifier does not match code_challenge';
304+
}
305+
306+
checks.push({
307+
id: 'pkce-verifier-matches-challenge',
308+
name: 'PKCE Verifier Validation',
309+
description,
310+
status: matches ? 'SUCCESS' : 'FAILURE',
311+
timestamp,
312+
specReferences: [SpecReferences.MCP_PKCE],
313+
details: {
314+
matches,
315+
storedChallenge: storedCodeChallenge || 'not sent',
316+
computedChallenge: computedChallenge || 'not computed'
317+
}
318+
});
319+
}
320+
209321
let token = `test-token-${Date.now()}`;
210322
let scopes: string[] = lastAuthorizationScopes;
211323

src/scenarios/client/auth/index.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { runClient as ignoreScopeClient } from '../../../../examples/clients/typ
99
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
1010
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
1111
import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit';
12+
import { runClient as noPkceClient } from '../../../../examples/clients/typescript/auth-test-no-pkce';
1213
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
1314
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';
1415

@@ -99,4 +100,16 @@ describe('Negative tests', () => {
99100
allowClientError: true
100101
});
101102
});
103+
104+
test('client does not use PKCE', async () => {
105+
const runner = new InlineClientRunner(noPkceClient);
106+
await runClientAgainstScenario(runner, 'auth/metadata-default', {
107+
expectedFailureSlugs: [
108+
'pkce-code-challenge-sent',
109+
'pkce-s256-method-used',
110+
'pkce-code-verifier-sent',
111+
'pkce-verifier-matches-challenge'
112+
]
113+
});
114+
});
102115
});

src/scenarios/client/auth/spec-references.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,9 @@ export const SpecReferences: { [key: string]: SpecReference } = {
7676
MCP_PREREGISTRATION: {
7777
id: 'MCP-Preregistration',
7878
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration'
79+
},
80+
MCP_PKCE: {
81+
id: 'MCP-PKCE-requirement',
82+
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection'
7983
}
8084
};

0 commit comments

Comments
 (0)