feat: add resource parameter validation tests (RFC 8707) (#118)

* 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

* 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)
This commit is contained in:
Paul Carleton 2026-01-23 18:21:46 +00:00 • committed by GitHub
commit 67ad358019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 264 additions and 6 deletions

View file

@ -144,7 +144,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
);

View file

@ -59,6 +59,7 @@ export interface AuthServerOptions {
onAuthorizationRequest?: (requestData: {
clientId?: string;
scope?: string;
resource?: string;
timestamp: string;
}) => void;
onRegistrationRequest?: (req: Request) => {
@ -230,6 +231,7 @@ export function createAuthServer(
onAuthorizationRequest({
clientId: req.query.client_id as string | undefined,
scope: scopeParam,
resource: req.query.resource as string | undefined,
timestamp
});
}

View file

@ -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,

View file

@ -23,7 +23,9 @@ const skipScenarios = new Set<string>([
const allowClientErrorScenarios = new Set<string>([
// 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', () => {

View file

@ -21,6 +21,7 @@ import {
ClientCredentialsJwtScenario,
ClientCredentialsBasicScenario
} from './client-credentials';
import { ResourceMismatchScenario } from './resource-mismatch';
import { PreRegistrationScenario } from './pre-registration';
// Auth scenarios (required for tier 1)
@ -37,6 +38,7 @@ export const authScenariosList: Scenario[] = [
new ClientSecretBasicAuthScenario(),
new ClientSecretPostAuthScenario(),
new PublicClientAuthScenario(),
new ResourceMismatchScenario(),
new PreRegistrationScenario()
];

View file

@ -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:<port>/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<ScenarioUrls> {
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;
}
}

View file

@ -73,6 +73,14 @@ export const SpecReferences: { [key: string]: SpecReference } = {
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'
},
MCP_PREREGISTRATION: {
id: 'MCP-Preregistration',
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration'

View file

@ -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<ScenarioUrls> {
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,132 @@ 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 MUST be consistent between authorization and token requests',
status: consistent ? 'SUCCESS' : 'FAILURE',
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 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 {