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:
parent
4a2ba8ae7b
commit
67ad358019
8 changed files with 264 additions and 6 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
];
|
||||
|
||||
|
|
|
|||
112
src/scenarios/client/auth/resource-mismatch.ts
Normal file
112
src/scenarios/client/auth/resource-mismatch.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue