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
4 changes: 3 additions & 1 deletion examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
2 changes: 2 additions & 0 deletions src/scenarios/client/auth/helpers/createAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface AuthServerOptions {
onAuthorizationRequest?: (requestData: {
clientId?: string;
scope?: string;
resource?: string;
timestamp: string;
}) => void;
onRegistrationRequest?: (req: Request) => {
Expand Down Expand Up @@ -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
});
}
Expand Down
11 changes: 8 additions & 3 deletions src/scenarios/client/auth/helpers/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -36,7 +38,8 @@ export function createServer(
scopesSupported,
includePrmInWwwAuth = true,
includeScopeInWwwAuth = false,
tokenVerifier
tokenVerifier,
prmResourceOverride
} = options;
const server = new Server(
{
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/scenarios/client/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/scenarios/client/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -37,6 +38,7 @@ export const authScenariosList: Scenario[] = [
new ClientSecretBasicAuthScenario(),
new ClientSecretPostAuthScenario(),
new PublicClientAuthScenario(),
new ResourceMismatchScenario(),
new PreRegistrationScenario()
];

Expand Down
112 changes: 112 additions & 0 deletions src/scenarios/client/auth/resource-mismatch.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions src/scenarios/client/auth/spec-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
127 changes: 126 additions & 1 deletion src/scenarios/client/auth/token-endpoint-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading