From ce9b561cd575faf7c10c479d8df88373de6c3ed9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 10:00:34 +0000 Subject: [PATCH 1/2] feat: add resource parameter validation tests (RFC 8707) Adds conformance tests for OAuth Resource Indicators (RFC 8707) implementation: 1. Resource parameter checks added to token-endpoint-auth-basic scenario: - resource-parameter-in-authorization: Verify resource in auth request - resource-parameter-in-token: Verify resource in token request - resource-parameter-valid-uri: Verify valid canonical URI - resource-parameter-consistency: Verify consistency between requests 2. New auth/resource-mismatch scenario: - Tests that client rejects when PRM resource doesn't match server URL - Server returns mismatched resource in PRM - Test passes if client does NOT proceed with authorization Also adds spec references for RFC 8707 and MCP resource parameter spec. Closes #33 --- .../clients/typescript/everything-client.ts | 4 +- .../client/auth/helpers/createAuthServer.ts | 2 + .../client/auth/helpers/createServer.ts | 11 +- src/scenarios/client/auth/index.test.ts | 4 +- src/scenarios/client/auth/index.ts | 4 +- .../client/auth/resource-mismatch.ts | 112 +++++++++++++++ src/scenarios/client/auth/spec-references.ts | 8 ++ .../client/auth/token-endpoint-auth.ts | 131 +++++++++++++++++- 8 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 src/scenarios/client/auth/resource-mismatch.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 4491fe6..5d9656a 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -139,7 +139,9 @@ registerScenarios( // Token endpoint auth method scenarios 'auth/token-endpoint-auth-basic', 'auth/token-endpoint-auth-post', - 'auth/token-endpoint-auth-none' + 'auth/token-endpoint-auth-none', + // Resource mismatch (client should error when PRM resource doesn't match) + 'auth/resource-mismatch' ], runAuthClient ); diff --git a/src/scenarios/client/auth/helpers/createAuthServer.ts b/src/scenarios/client/auth/helpers/createAuthServer.ts index 1071828..a6b4fc3 100644 --- a/src/scenarios/client/auth/helpers/createAuthServer.ts +++ b/src/scenarios/client/auth/helpers/createAuthServer.ts @@ -41,6 +41,7 @@ export interface AuthServerOptions { onAuthorizationRequest?: (requestData: { clientId?: string; scope?: string; + resource?: string; timestamp: string; }) => void; onRegistrationRequest?: (req: Request) => { @@ -168,6 +169,7 @@ export function createAuthServer( onAuthorizationRequest({ clientId: req.query.client_id as string | undefined, scope: scopeParam, + resource: req.query.resource as string | undefined, timestamp }); } diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index de230c8..35b89b1 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -22,6 +22,8 @@ export interface ServerOptions { includeScopeInWwwAuth?: boolean; authMiddleware?: express.RequestHandler; tokenVerifier?: MockTokenVerifier; + /** Override the resource field in PRM response (for testing resource mismatch) */ + prmResourceOverride?: string; } export function createServer( @@ -36,7 +38,8 @@ export function createServer( scopesSupported, includePrmInWwwAuth = true, includeScopeInWwwAuth = false, - tokenVerifier + tokenVerifier, + prmResourceOverride } = options; const server = new Server( { @@ -107,10 +110,12 @@ export function createServer( // Resource is usually $baseUrl/mcp, but if PRM is at the root, // the resource identifier is the root. + // Can be overridden via prmResourceOverride for testing resource mismatch. const resource = - prmPath === '/.well-known/oauth-protected-resource' + prmResourceOverride ?? + (prmPath === '/.well-known/oauth-protected-resource' ? getBaseUrl() - : `${getBaseUrl()}/mcp`; + : `${getBaseUrl()}/mcp`); const prmResponse: any = { resource, diff --git a/src/scenarios/client/auth/index.test.ts b/src/scenarios/client/auth/index.test.ts index 6dcc020..d975ba5 100644 --- a/src/scenarios/client/auth/index.test.ts +++ b/src/scenarios/client/auth/index.test.ts @@ -22,7 +22,9 @@ const skipScenarios = new Set([ const allowClientErrorScenarios = new Set([ // Client is expected to give up (error) after limited retries, but check should pass - 'auth/scope-retry-limit' + 'auth/scope-retry-limit', + // Client is expected to error when PRM resource doesn't match server URL + 'auth/resource-mismatch' ]); describe('Client Auth Scenarios', () => { diff --git a/src/scenarios/client/auth/index.ts b/src/scenarios/client/auth/index.ts index 805781a..848867e 100644 --- a/src/scenarios/client/auth/index.ts +++ b/src/scenarios/client/auth/index.ts @@ -21,6 +21,7 @@ import { ClientCredentialsJwtScenario, ClientCredentialsBasicScenario } from './client-credentials'; +import { ResourceMismatchScenario } from './resource-mismatch'; // Auth scenarios (required for tier 1) export const authScenariosList: Scenario[] = [ @@ -35,7 +36,8 @@ export const authScenariosList: Scenario[] = [ new ScopeRetryLimitScenario(), new ClientSecretBasicAuthScenario(), new ClientSecretPostAuthScenario(), - new PublicClientAuthScenario() + new PublicClientAuthScenario(), + new ResourceMismatchScenario() ]; // Extension scenarios (optional for tier 1 - protocol extensions) diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts new file mode 100644 index 0000000..4783bc8 --- /dev/null +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -0,0 +1,112 @@ +import type { Scenario, ConformanceCheck } from '../../../types.js'; +import { ScenarioUrls } from '../../../types.js'; +import { createAuthServer } from './helpers/createAuthServer.js'; +import { createServer } from './helpers/createServer.js'; +import { ServerLifecycle } from './helpers/serverLifecycle.js'; +import { SpecReferences } from './spec-references.js'; +import { MockTokenVerifier } from './helpers/mockTokenVerifier.js'; + +/** + * Scenario: Resource Mismatch Detection + * + * Tests that clients correctly detect and reject when the Protected Resource + * Metadata returns a `resource` field that doesn't match the server URL + * the client is trying to access. + * + * Per RFC 8707 and MCP spec, clients MUST validate that the resource from + * PRM matches the expected server before proceeding with authorization. + * + * Setup: + * - Server returns PRM with resource: "https://evil.example.com/mcp" (different origin) + * - Client is trying to access the actual server at localhost:/mcp + * + * Expected behavior: + * - Client should NOT proceed with authorization + * - Client should abort due to resource mismatch + * - Test passes if client does NOT complete the auth flow (no authorization request) + */ +export class ResourceMismatchScenario implements Scenario { + name = 'auth/resource-mismatch'; + description = + 'Tests that client rejects when PRM resource does not match server URL'; + + private authServer = new ServerLifecycle(); + private server = new ServerLifecycle(); + private checks: ConformanceCheck[] = []; + private authorizationRequestMade = false; + + async start(): Promise { + this.checks = []; + this.authorizationRequestMade = false; + + const tokenVerifier = new MockTokenVerifier(this.checks, []); + + const authApp = createAuthServer(this.checks, this.authServer.getUrl, { + tokenVerifier, + tokenEndpointAuthMethodsSupported: ['none'], + onAuthorizationRequest: () => { + // If we get here, the client incorrectly proceeded with auth + this.authorizationRequestMade = true; + }, + onRegistrationRequest: () => ({ + clientId: `test-client-${Date.now()}`, + clientSecret: undefined, + tokenEndpointAuthMethod: 'none' + }) + }); + await this.authServer.start(authApp); + + // Create server that returns a mismatched resource in PRM + const app = createServer( + this.checks, + this.server.getUrl, + this.authServer.getUrl, + { + prmPath: '/.well-known/oauth-protected-resource/mcp', + requiredScopes: [], + tokenVerifier, + // Return a different origin in PRM - this should be rejected by the client + prmResourceOverride: 'https://evil.example.com/mcp' + } + ); + await this.server.start(app); + + return { serverUrl: `${this.server.getUrl()}/mcp` }; + } + + async stop() { + await this.authServer.stop(); + await this.server.stop(); + } + + getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + const specRefs = [ + SpecReferences.RFC_8707_RESOURCE_INDICATORS, + SpecReferences.MCP_RESOURCE_PARAMETER + ]; + + // The test passes if the client did NOT make an authorization request + // (meaning it correctly rejected the mismatched resource) + if (!this.checks.some((c) => c.id === 'resource-mismatch-rejected')) { + const correctlyRejected = !this.authorizationRequestMade; + this.checks.push({ + id: 'resource-mismatch-rejected', + name: 'Client rejects mismatched resource', + description: correctlyRejected + ? 'Client correctly rejected authorization when PRM resource does not match server URL' + : 'Client MUST validate that PRM resource matches the server URL before proceeding with authorization', + status: correctlyRejected ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + prmResource: 'https://evil.example.com/mcp', + expectedBehavior: 'Client should NOT proceed with authorization', + authorizationRequestMade: this.authorizationRequestMade + } + }); + } + + return this.checks; + } +} diff --git a/src/scenarios/client/auth/spec-references.ts b/src/scenarios/client/auth/spec-references.ts index 52a08ca..d889b1a 100644 --- a/src/scenarios/client/auth/spec-references.ts +++ b/src/scenarios/client/auth/spec-references.ts @@ -72,5 +72,13 @@ export const SpecReferences: { [key: string]: SpecReference } = { SEP_1046_CLIENT_CREDENTIALS: { id: 'SEP-1046-Client-Credentials', url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx' + }, + RFC_8707_RESOURCE_INDICATORS: { + id: 'RFC-8707-Resource-Indicators', + url: 'https://www.rfc-editor.org/rfc/rfc8707.html' + }, + MCP_RESOURCE_PARAMETER: { + id: 'MCP-Resource-Parameter-Implementation', + url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#resource-parameter-implementation' } }; diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 4203789..b4caf0e 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -51,6 +51,10 @@ class TokenEndpointAuthScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; + // Track resource parameters for RFC 8707 validation + private authorizationResource?: string; + private tokenResource?: string; + constructor(expectedAuthMethod: AuthMethod) { this.expectedAuthMethod = expectedAuthMethod; this.name = `auth/token-endpoint-auth-${expectedAuthMethod === 'client_secret_basic' ? 'basic' : expectedAuthMethod === 'client_secret_post' ? 'post' : 'none'}`; @@ -59,12 +63,19 @@ class TokenEndpointAuthScenario implements Scenario { async start(): Promise { this.checks = []; + this.authorizationResource = undefined; + this.tokenResource = undefined; const tokenVerifier = new MockTokenVerifier(this.checks, []); const authApp = createAuthServer(this.checks, this.authServer.getUrl, { tokenVerifier, tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod], + onAuthorizationRequest: ({ resource }) => { + this.authorizationResource = resource; + }, onTokenRequest: ({ authorizationHeader, body, timestamp }) => { + // Track resource from token request for RFC 8707 validation + this.tokenResource = body.resource; const bodyClientSecret = body.client_secret; const actualMethod = detectAuthMethod( authorizationHeader, @@ -145,18 +156,136 @@ class TokenEndpointAuthScenario implements Scenario { } getChecks(): ConformanceCheck[] { + const timestamp = new Date().toISOString(); + if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) { this.checks.push({ id: 'token-endpoint-auth-method', name: 'Token endpoint authentication method', description: 'Client did not make a token request', status: 'FAILURE', - timestamp: new Date().toISOString(), + timestamp, specReferences: [SpecReferences.OAUTH_2_1_TOKEN] }); } + + // RFC 8707 Resource Parameter Validation Checks + this.addResourceParameterChecks(timestamp); + return this.checks; } + + private addResourceParameterChecks(timestamp: string): void { + const specRefs = [ + SpecReferences.RFC_8707_RESOURCE_INDICATORS, + SpecReferences.MCP_RESOURCE_PARAMETER + ]; + + // Check 1: Resource parameter in authorization request + if ( + !this.checks.some((c) => c.id === 'resource-parameter-in-authorization') + ) { + const hasResource = !!this.authorizationResource; + this.checks.push({ + id: 'resource-parameter-in-authorization', + name: 'Resource parameter in authorization request', + description: hasResource + ? 'Client included resource parameter in authorization request' + : 'Client MUST include resource parameter in authorization request per RFC 8707', + status: hasResource ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + resource: this.authorizationResource || 'not provided' + } + }); + } + + // Check 2: Resource parameter in token request + if (!this.checks.some((c) => c.id === 'resource-parameter-in-token')) { + const hasResource = !!this.tokenResource; + this.checks.push({ + id: 'resource-parameter-in-token', + name: 'Resource parameter in token request', + description: hasResource + ? 'Client included resource parameter in token request' + : 'Client MUST include resource parameter in token request per RFC 8707', + status: hasResource ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + resource: this.tokenResource || 'not provided' + } + }); + } + + // Check 3: Resource parameter is valid canonical URI + if (!this.checks.some((c) => c.id === 'resource-parameter-valid-uri')) { + const resourceToValidate = + this.authorizationResource || this.tokenResource; + if (resourceToValidate) { + const validation = this.validateCanonicalUri(resourceToValidate); + this.checks.push({ + id: 'resource-parameter-valid-uri', + name: 'Resource parameter is valid canonical URI', + description: validation.valid + ? 'Resource parameter is a valid canonical URI (has scheme, no fragment)' + : `Resource parameter is invalid: ${validation.error}`, + status: validation.valid ? 'SUCCESS' : 'FAILURE', + timestamp, + specReferences: specRefs, + details: { + resource: resourceToValidate, + ...(validation.error && { error: validation.error }) + } + }); + } + } + + // Check 4: Resource parameter consistency between requests + if (!this.checks.some((c) => c.id === 'resource-parameter-consistency')) { + if (this.authorizationResource && this.tokenResource) { + const consistent = this.authorizationResource === this.tokenResource; + this.checks.push({ + id: 'resource-parameter-consistency', + name: 'Resource parameter consistency', + description: consistent + ? 'Resource parameter is consistent between authorization and token requests' + : 'Resource parameter SHOULD be consistent between authorization and token requests', + status: consistent ? 'SUCCESS' : 'WARNING', + timestamp, + specReferences: specRefs, + details: { + authorizationResource: this.authorizationResource, + tokenResource: this.tokenResource + } + }); + } + } + } + + private validateCanonicalUri(uri: string): { + valid: boolean; + error?: string; + } { + try { + const parsed = new URL(uri); + // Check for scheme (URL constructor requires it, so if we get here it has one) + if (!parsed.protocol) { + return { valid: false, error: 'missing scheme' }; + } + // Check for fragment (RFC 8707: MUST NOT include fragment) + if (parsed.hash) { + return { + valid: false, + error: 'contains fragment (not allowed per RFC 8707)' + }; + } + return { valid: true }; + } catch { + return { valid: false, error: 'invalid URI format' }; + } + } } export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario { From e54eabecf5c116fac4ee1ab47d40849ad00463dd Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Fri, 23 Jan 2026 11:30:52 +0000 Subject: [PATCH 2/2] fix: make resource consistency check a FAILURE and remove dead code - Change resource parameter consistency from WARNING to FAILURE - Remove unreachable protocol check in validateCanonicalUri (URL constructor already validates scheme presence) --- src/scenarios/client/auth/token-endpoint-auth.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index b4caf0e..80b80f7 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -251,8 +251,8 @@ class TokenEndpointAuthScenario implements Scenario { name: 'Resource parameter consistency', description: consistent ? 'Resource parameter is consistent between authorization and token requests' - : 'Resource parameter SHOULD be consistent between authorization and token requests', - status: consistent ? 'SUCCESS' : 'WARNING', + : 'Resource parameter MUST be consistent between authorization and token requests', + status: consistent ? 'SUCCESS' : 'FAILURE', timestamp, specReferences: specRefs, details: { @@ -270,10 +270,6 @@ class TokenEndpointAuthScenario implements Scenario { } { try { const parsed = new URL(uri); - // Check for scheme (URL constructor requires it, so if we get here it has one) - if (!parsed.protocol) { - return { valid: false, error: 'missing scheme' }; - } // Check for fragment (RFC 8707: MUST NOT include fragment) if (parsed.hash) { return {