diff --git a/examples/clients/typescript/auth-test-no-pkce.ts b/examples/clients/typescript/auth-test-no-pkce.ts new file mode 100644 index 0000000..308351d --- /dev/null +++ b/examples/clients/typescript/auth-test-no-pkce.ts @@ -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 { + // 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 => { + const makeRequest = async (): Promise => { + 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 { + 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 '); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 9d06cfa..af6fe42 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -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[]; @@ -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; @@ -68,6 +84,7 @@ export function createAuthServer( tokenEndpointAuthSigningAlgValuesSupported, clientIdMetadataDocumentSupported, disableDynamicRegistration = false, + codeChallengeMethodsSupported = ['S256'], tokenVerifier, onTokenRequest, onAuthorizationRequest, @@ -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`, @@ -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: @@ -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(' ') : []; @@ -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; diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 6dcc020..bda5d40 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -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'; @@ -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' + ] + }); + }); }); diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 9eda138..a8800cd 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -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' } };