Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions examples/clients/typescript/auth-test-no-pkce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env node

/**
* Broken client that doesn't use PKCE.
*
* BUG: Skips PKCE entirely - doesn't send code_challenge in authorization
* request and doesn't send code_verifier in token request.
*
* Per MCP spec: "MCP clients MUST implement PKCE according to OAuth 2.1"
*/

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { extractWWWAuthenticateParams } from '@modelcontextprotocol/sdk/client/auth.js';
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { Middleware } from '@modelcontextprotocol/sdk/client/middleware.js';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';

interface OAuthTokens {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
}

/**
* Custom OAuth flow that deliberately skips PKCE.
* This is intentionally broken behavior for conformance testing.
*/
async function oauthFlowWithoutPkce(
_serverUrl: string | URL,
resourceMetadataUrl: string | URL,
fetchFn: FetchLike
): Promise<OAuthTokens> {
// 1. Fetch Protected Resource Metadata
const prmResponse = await fetchFn(resourceMetadataUrl);
if (!prmResponse.ok) {
throw new Error(`Failed to fetch PRM: ${prmResponse.status}`);
}
const prm = await prmResponse.json();
const authServerUrl = prm.authorization_servers?.[0];
if (!authServerUrl) {
throw new Error('No authorization server in PRM');
}

// 2. Fetch Authorization Server Metadata
const asMetadataUrl = new URL(
'/.well-known/oauth-authorization-server',
authServerUrl
);
const asResponse = await fetchFn(asMetadataUrl.toString());
if (!asResponse.ok) {
throw new Error(`Failed to fetch AS metadata: ${asResponse.status}`);
}
const asMetadata = await asResponse.json();

// 3. Register client (DCR)
const dcrResponse = await fetchFn(asMetadata.registration_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'test-auth-client-no-pkce',
redirect_uris: ['http://localhost:3000/callback']
})
});
if (!dcrResponse.ok) {
throw new Error(`DCR failed: ${dcrResponse.status}`);
}
const clientInfo = await dcrResponse.json();

// 4. Build authorization URL WITHOUT PKCE (BUG!)
const authUrl = new URL(asMetadata.authorization_endpoint);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', clientInfo.client_id);
authUrl.searchParams.set('redirect_uri', 'http://localhost:3000/callback');
authUrl.searchParams.set('state', 'test-state');
// BUG: NOT setting code_challenge or code_challenge_method

// 5. Fetch authorization endpoint (simulates redirect)
const authResponse = await fetchFn(authUrl.toString(), {
redirect: 'manual'
});
const location = authResponse.headers.get('location');
if (!location) {
throw new Error('No redirect from authorization endpoint');
}
const redirectUrl = new URL(location);
const authCode = redirectUrl.searchParams.get('code');
if (!authCode) {
throw new Error('No auth code in redirect');
}

// 6. Exchange code for token WITHOUT code_verifier (BUG!)
const tokenResponse = await fetchFn(asMetadata.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: 'http://localhost:3000/callback',
client_id: clientInfo.client_id
// BUG: NOT sending code_verifier
}).toString()
});

if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token request failed: ${tokenResponse.status} - ${error}`);
}

return tokenResponse.json();
}

/**
* Creates a fetch wrapper that uses OAuth without PKCE.
*/
function withOAuthNoPkce(baseUrl: string | URL): Middleware {
let tokens: OAuthTokens | undefined;

return (next: FetchLike) => {
return async (
input: string | URL,
init?: RequestInit
): Promise<Response> => {
const makeRequest = async (): Promise<Response> => {
const headers = new Headers(init?.headers);
if (tokens) {
headers.set('Authorization', `Bearer ${tokens.access_token}`);
}
return next(input, { ...init, headers });
};

let response = await makeRequest();

if (response.status === 401) {
const { resourceMetadataUrl } = extractWWWAuthenticateParams(response);
if (!resourceMetadataUrl) {
throw new Error('No resource_metadata in WWW-Authenticate');
}
tokens = await oauthFlowWithoutPkce(baseUrl, resourceMetadataUrl, next);
response = await makeRequest();
}

return response;
};
};
}

export async function runClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-auth-client-no-pkce', version: '1.0.0' },
{ capabilities: {} }
);

const oauthFetch = withOAuthNoPkce(new URL(serverUrl))(fetch);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});

await client.connect(transport);
logger.debug('Successfully connected to MCP server');

await client.listTools();
logger.debug('Successfully listed tools');

await transport.close();
logger.debug('Connection closed successfully');
}

runAsCli(runClient, import.meta.url, 'auth-test-no-pkce <server-url>');
114 changes: 113 additions & 1 deletion src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import express, { Request, Response } from 'express';
import { createHash } from 'crypto';
import type { ConformanceCheck } from '../../../../types';
import { createRequestLogger } from '../../../request-logger';
import { SpecReferences } from '../spec-references';
import { MockTokenVerifier } from './mockTokenVerifier';

/**
* Compute S256 code challenge from a code verifier.
* BASE64URL(SHA256(code_verifier))
*/
function computeS256Challenge(codeVerifier: string): string {
const hash = createHash('sha256').update(codeVerifier).digest();
return hash
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

export interface TokenRequestResult {
token: string;
scopes: string[];
Expand All @@ -27,6 +41,8 @@ export interface AuthServerOptions {
clientIdMetadataDocumentSupported?: boolean;
/** Set to true to NOT advertise registration_endpoint (for pre-registration tests) */
disableDynamicRegistration?: boolean;
/** PKCE code_challenge_methods_supported. Set to null to omit from metadata. Default: ['S256'] */
codeChallengeMethodsSupported?: string[] | null;
tokenVerifier?: MockTokenVerifier;
onTokenRequest?: (requestData: {
scope?: string;
Expand Down Expand Up @@ -68,6 +84,7 @@ export function createAuthServer(
tokenEndpointAuthSigningAlgValuesSupported,
clientIdMetadataDocumentSupported,
disableDynamicRegistration = false,
codeChallengeMethodsSupported = ['S256'],
tokenVerifier,
onTokenRequest,
onAuthorizationRequest,
Expand All @@ -76,6 +93,8 @@ export function createAuthServer(

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

const authRoutes = {
authorization_endpoint: `${routePrefix}/authorize`,
Expand Down Expand Up @@ -122,7 +141,10 @@ export function createAuthServer(
}),
response_types_supported: ['code'],
grant_types_supported: grantTypesSupported,
code_challenge_methods_supported: ['S256'],
// PKCE support - null means omit from metadata (for negative testing)
...(codeChallengeMethodsSupported !== null && {
code_challenge_methods_supported: codeChallengeMethodsSupported
}),
token_endpoint_auth_methods_supported: tokenEndpointAuthMethodsSupported,
...(tokenEndpointAuthSigningAlgValuesSupported && {
token_endpoint_auth_signing_alg_values_supported:
Expand Down Expand Up @@ -165,6 +187,41 @@ export function createAuthServer(
}
});

// PKCE: Store code_challenge for later verification
const codeChallenge = req.query.code_challenge as string | undefined;
const codeChallengeMethod = req.query.code_challenge_method as
| string
| undefined;
storedCodeChallenge = codeChallenge;

// PKCE: Check code_challenge is present
checks.push({
id: 'pkce-code-challenge-sent',
name: 'PKCE Code Challenge',
description: codeChallenge
? 'Client sent code_challenge in authorization request'
: 'Client MUST send code_challenge in authorization request',
status: codeChallenge ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SpecReferences.MCP_PKCE]
});

// PKCE: Check S256 method is used
checks.push({
id: 'pkce-s256-method-used',
name: 'PKCE S256 Method',
description:
codeChallengeMethod === 'S256'
? 'Client used S256 code challenge method'
: 'Client MUST use S256 code challenge method when technically capable',
status: codeChallengeMethod === 'S256' ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SpecReferences.MCP_PKCE],
details: {
method: codeChallengeMethod || 'not specified'
}
});

// Track scopes from authorization request for token issuance
const scopeParam = req.query.scope as string | undefined;
lastAuthorizationScopes = scopeParam ? scopeParam.split(' ') : [];
Expand Down Expand Up @@ -206,6 +263,61 @@ export function createAuthServer(
}
});

// PKCE: Check code_verifier is present (only for authorization_code grant)
const codeVerifier = req.body.code_verifier as string | undefined;
if (grantType === 'authorization_code') {
checks.push({
id: 'pkce-code-verifier-sent',
name: 'PKCE Code Verifier',
description: codeVerifier
? 'Client sent code_verifier in token request'
: 'Client MUST send code_verifier in token request',
status: codeVerifier ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SpecReferences.MCP_PKCE]
});

// PKCE: Validate code_verifier matches code_challenge (S256)
// Fail if either is missing
const computedChallenge =
codeVerifier && storedCodeChallenge
? computeS256Challenge(codeVerifier)
: undefined;
const matches =
computedChallenge !== undefined &&
computedChallenge === storedCodeChallenge;

let description: string;
if (!storedCodeChallenge && !codeVerifier) {
description =
'Neither code_challenge nor code_verifier were sent - PKCE is required';
} else if (!storedCodeChallenge) {
description =
'code_challenge was not sent in authorization request - PKCE is required';
} else if (!codeVerifier) {
description =
'code_verifier was not sent in token request - PKCE is required';
} else if (matches) {
description = 'code_verifier correctly matches code_challenge (S256)';
} else {
description = 'code_verifier does not match code_challenge';
}

checks.push({
id: 'pkce-verifier-matches-challenge',
name: 'PKCE Verifier Validation',
description,
status: matches ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [SpecReferences.MCP_PKCE],
details: {
matches,
storedChallenge: storedCodeChallenge || 'not sent',
computedChallenge: computedChallenge || 'not computed'
}
});
}

let token = `test-token-${Date.now()}`;
let scopes: string[] = lastAuthorizationScopes;

Expand Down
13 changes: 13 additions & 0 deletions src/scenarios/client/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { runClient as ignoreScopeClient } from '../../../../examples/clients/typ
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
import { runClient as ignore403Client } from '../../../../examples/clients/typescript/auth-test-ignore-403';
import { runClient as noRetryLimitClient } from '../../../../examples/clients/typescript/auth-test-no-retry-limit';
import { runClient as noPkceClient } from '../../../../examples/clients/typescript/auth-test-no-pkce';
import { getHandler } from '../../../../examples/clients/typescript/everything-client';
import { setLogLevel } from '../../../../examples/clients/typescript/helpers/logger';

Expand Down Expand Up @@ -99,4 +100,16 @@ describe('Negative tests', () => {
allowClientError: true
});
});

test('client does not use PKCE', async () => {
const runner = new InlineClientRunner(noPkceClient);
await runClientAgainstScenario(runner, 'auth/metadata-default', {
expectedFailureSlugs: [
'pkce-code-challenge-sent',
'pkce-s256-method-used',
'pkce-code-verifier-sent',
'pkce-verifier-matches-challenge'
]
});
});
});
4 changes: 4 additions & 0 deletions src/scenarios/client/auth/spec-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,9 @@ export const SpecReferences: { [key: string]: SpecReference } = {
MCP_PREREGISTRATION: {
id: 'MCP-Preregistration',
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration'
},
MCP_PKCE: {
id: 'MCP-PKCE-requirement',
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-code-protection'
}
};
Loading