Skip to content

Commit 67ad358

Browse files
authored
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)
1 parent 4a2ba8a commit 67ad358

8 files changed

Lines changed: 264 additions & 6 deletions

File tree

examples/clients/typescript/everything-client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ registerScenarios(
144144
// Token endpoint auth method scenarios
145145
'auth/token-endpoint-auth-basic',
146146
'auth/token-endpoint-auth-post',
147-
'auth/token-endpoint-auth-none'
147+
'auth/token-endpoint-auth-none',
148+
// Resource mismatch (client should error when PRM resource doesn't match)
149+
'auth/resource-mismatch'
148150
],
149151
runAuthClient
150152
);

src/scenarios/client/auth/helpers/createAuthServer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface AuthServerOptions {
5959
onAuthorizationRequest?: (requestData: {
6060
clientId?: string;
6161
scope?: string;
62+
resource?: string;
6263
timestamp: string;
6364
}) => void;
6465
onRegistrationRequest?: (req: Request) => {
@@ -230,6 +231,7 @@ export function createAuthServer(
230231
onAuthorizationRequest({
231232
clientId: req.query.client_id as string | undefined,
232233
scope: scopeParam,
234+
resource: req.query.resource as string | undefined,
233235
timestamp
234236
});
235237
}

src/scenarios/client/auth/helpers/createServer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface ServerOptions {
2222
includeScopeInWwwAuth?: boolean;
2323
authMiddleware?: express.RequestHandler;
2424
tokenVerifier?: MockTokenVerifier;
25+
/** Override the resource field in PRM response (for testing resource mismatch) */
26+
prmResourceOverride?: string;
2527
}
2628

2729
export function createServer(
@@ -36,7 +38,8 @@ export function createServer(
3638
scopesSupported,
3739
includePrmInWwwAuth = true,
3840
includeScopeInWwwAuth = false,
39-
tokenVerifier
41+
tokenVerifier,
42+
prmResourceOverride
4043
} = options;
4144
const server = new Server(
4245
{
@@ -107,10 +110,12 @@ export function createServer(
107110

108111
// Resource is usually $baseUrl/mcp, but if PRM is at the root,
109112
// the resource identifier is the root.
113+
// Can be overridden via prmResourceOverride for testing resource mismatch.
110114
const resource =
111-
prmPath === '/.well-known/oauth-protected-resource'
115+
prmResourceOverride ??
116+
(prmPath === '/.well-known/oauth-protected-resource'
112117
? getBaseUrl()
113-
: `${getBaseUrl()}/mcp`;
118+
: `${getBaseUrl()}/mcp`);
114119

115120
const prmResponse: any = {
116121
resource,

src/scenarios/client/auth/index.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ const skipScenarios = new Set<string>([
2323

2424
const allowClientErrorScenarios = new Set<string>([
2525
// Client is expected to give up (error) after limited retries, but check should pass
26-
'auth/scope-retry-limit'
26+
'auth/scope-retry-limit',
27+
// Client is expected to error when PRM resource doesn't match server URL
28+
'auth/resource-mismatch'
2729
]);
2830

2931
describe('Client Auth Scenarios', () => {

src/scenarios/client/auth/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
ClientCredentialsJwtScenario,
2222
ClientCredentialsBasicScenario
2323
} from './client-credentials';
24+
import { ResourceMismatchScenario } from './resource-mismatch';
2425
import { PreRegistrationScenario } from './pre-registration';
2526

2627
// Auth scenarios (required for tier 1)
@@ -37,6 +38,7 @@ export const authScenariosList: Scenario[] = [
3738
new ClientSecretBasicAuthScenario(),
3839
new ClientSecretPostAuthScenario(),
3940
new PublicClientAuthScenario(),
41+
new ResourceMismatchScenario(),
4042
new PreRegistrationScenario()
4143
];
4244

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { Scenario, ConformanceCheck } from '../../../types.js';
2+
import { ScenarioUrls } from '../../../types.js';
3+
import { createAuthServer } from './helpers/createAuthServer.js';
4+
import { createServer } from './helpers/createServer.js';
5+
import { ServerLifecycle } from './helpers/serverLifecycle.js';
6+
import { SpecReferences } from './spec-references.js';
7+
import { MockTokenVerifier } from './helpers/mockTokenVerifier.js';
8+
9+
/**
10+
* Scenario: Resource Mismatch Detection
11+
*
12+
* Tests that clients correctly detect and reject when the Protected Resource
13+
* Metadata returns a `resource` field that doesn't match the server URL
14+
* the client is trying to access.
15+
*
16+
* Per RFC 8707 and MCP spec, clients MUST validate that the resource from
17+
* PRM matches the expected server before proceeding with authorization.
18+
*
19+
* Setup:
20+
* - Server returns PRM with resource: "https://evil.example.com/mcp" (different origin)
21+
* - Client is trying to access the actual server at localhost:<port>/mcp
22+
*
23+
* Expected behavior:
24+
* - Client should NOT proceed with authorization
25+
* - Client should abort due to resource mismatch
26+
* - Test passes if client does NOT complete the auth flow (no authorization request)
27+
*/
28+
export class ResourceMismatchScenario implements Scenario {
29+
name = 'auth/resource-mismatch';
30+
description =
31+
'Tests that client rejects when PRM resource does not match server URL';
32+
33+
private authServer = new ServerLifecycle();
34+
private server = new ServerLifecycle();
35+
private checks: ConformanceCheck[] = [];
36+
private authorizationRequestMade = false;
37+
38+
async start(): Promise<ScenarioUrls> {
39+
this.checks = [];
40+
this.authorizationRequestMade = false;
41+
42+
const tokenVerifier = new MockTokenVerifier(this.checks, []);
43+
44+
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
45+
tokenVerifier,
46+
tokenEndpointAuthMethodsSupported: ['none'],
47+
onAuthorizationRequest: () => {
48+
// If we get here, the client incorrectly proceeded with auth
49+
this.authorizationRequestMade = true;
50+
},
51+
onRegistrationRequest: () => ({
52+
clientId: `test-client-${Date.now()}`,
53+
clientSecret: undefined,
54+
tokenEndpointAuthMethod: 'none'
55+
})
56+
});
57+
await this.authServer.start(authApp);
58+
59+
// Create server that returns a mismatched resource in PRM
60+
const app = createServer(
61+
this.checks,
62+
this.server.getUrl,
63+
this.authServer.getUrl,
64+
{
65+
prmPath: '/.well-known/oauth-protected-resource/mcp',
66+
requiredScopes: [],
67+
tokenVerifier,
68+
// Return a different origin in PRM - this should be rejected by the client
69+
prmResourceOverride: 'https://evil.example.com/mcp'
70+
}
71+
);
72+
await this.server.start(app);
73+
74+
return { serverUrl: `${this.server.getUrl()}/mcp` };
75+
}
76+
77+
async stop() {
78+
await this.authServer.stop();
79+
await this.server.stop();
80+
}
81+
82+
getChecks(): ConformanceCheck[] {
83+
const timestamp = new Date().toISOString();
84+
const specRefs = [
85+
SpecReferences.RFC_8707_RESOURCE_INDICATORS,
86+
SpecReferences.MCP_RESOURCE_PARAMETER
87+
];
88+
89+
// The test passes if the client did NOT make an authorization request
90+
// (meaning it correctly rejected the mismatched resource)
91+
if (!this.checks.some((c) => c.id === 'resource-mismatch-rejected')) {
92+
const correctlyRejected = !this.authorizationRequestMade;
93+
this.checks.push({
94+
id: 'resource-mismatch-rejected',
95+
name: 'Client rejects mismatched resource',
96+
description: correctlyRejected
97+
? 'Client correctly rejected authorization when PRM resource does not match server URL'
98+
: 'Client MUST validate that PRM resource matches the server URL before proceeding with authorization',
99+
status: correctlyRejected ? 'SUCCESS' : 'FAILURE',
100+
timestamp,
101+
specReferences: specRefs,
102+
details: {
103+
prmResource: 'https://evil.example.com/mcp',
104+
expectedBehavior: 'Client should NOT proceed with authorization',
105+
authorizationRequestMade: this.authorizationRequestMade
106+
}
107+
});
108+
}
109+
110+
return this.checks;
111+
}
112+
}

src/scenarios/client/auth/spec-references.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ export const SpecReferences: { [key: string]: SpecReference } = {
7373
id: 'SEP-1046-Client-Credentials',
7474
url: 'https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx'
7575
},
76+
RFC_8707_RESOURCE_INDICATORS: {
77+
id: 'RFC-8707-Resource-Indicators',
78+
url: 'https://www.rfc-editor.org/rfc/rfc8707.html'
79+
},
80+
MCP_RESOURCE_PARAMETER: {
81+
id: 'MCP-Resource-Parameter-Implementation',
82+
url: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#resource-parameter-implementation'
83+
},
7684
MCP_PREREGISTRATION: {
7785
id: 'MCP-Preregistration',
7886
url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#preregistration'

src/scenarios/client/auth/token-endpoint-auth.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ class TokenEndpointAuthScenario implements Scenario {
5151
private server = new ServerLifecycle();
5252
private checks: ConformanceCheck[] = [];
5353

54+
// Track resource parameters for RFC 8707 validation
55+
private authorizationResource?: string;
56+
private tokenResource?: string;
57+
5458
constructor(expectedAuthMethod: AuthMethod) {
5559
this.expectedAuthMethod = expectedAuthMethod;
5660
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 {
5963

6064
async start(): Promise<ScenarioUrls> {
6165
this.checks = [];
66+
this.authorizationResource = undefined;
67+
this.tokenResource = undefined;
6268
const tokenVerifier = new MockTokenVerifier(this.checks, []);
6369

6470
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
6571
tokenVerifier,
6672
tokenEndpointAuthMethodsSupported: [this.expectedAuthMethod],
73+
onAuthorizationRequest: ({ resource }) => {
74+
this.authorizationResource = resource;
75+
},
6776
onTokenRequest: ({ authorizationHeader, body, timestamp }) => {
77+
// Track resource from token request for RFC 8707 validation
78+
this.tokenResource = body.resource;
6879
const bodyClientSecret = body.client_secret;
6980
const actualMethod = detectAuthMethod(
7081
authorizationHeader,
@@ -145,18 +156,132 @@ class TokenEndpointAuthScenario implements Scenario {
145156
}
146157

147158
getChecks(): ConformanceCheck[] {
159+
const timestamp = new Date().toISOString();
160+
148161
if (!this.checks.some((c) => c.id === 'token-endpoint-auth-method')) {
149162
this.checks.push({
150163
id: 'token-endpoint-auth-method',
151164
name: 'Token endpoint authentication method',
152165
description: 'Client did not make a token request',
153166
status: 'FAILURE',
154-
timestamp: new Date().toISOString(),
167+
timestamp,
155168
specReferences: [SpecReferences.OAUTH_2_1_TOKEN]
156169
});
157170
}
171+
172+
// RFC 8707 Resource Parameter Validation Checks
173+
this.addResourceParameterChecks(timestamp);
174+
158175
return this.checks;
159176
}
177+
178+
private addResourceParameterChecks(timestamp: string): void {
179+
const specRefs = [
180+
SpecReferences.RFC_8707_RESOURCE_INDICATORS,
181+
SpecReferences.MCP_RESOURCE_PARAMETER
182+
];
183+
184+
// Check 1: Resource parameter in authorization request
185+
if (
186+
!this.checks.some((c) => c.id === 'resource-parameter-in-authorization')
187+
) {
188+
const hasResource = !!this.authorizationResource;
189+
this.checks.push({
190+
id: 'resource-parameter-in-authorization',
191+
name: 'Resource parameter in authorization request',
192+
description: hasResource
193+
? 'Client included resource parameter in authorization request'
194+
: 'Client MUST include resource parameter in authorization request per RFC 8707',
195+
status: hasResource ? 'SUCCESS' : 'FAILURE',
196+
timestamp,
197+
specReferences: specRefs,
198+
details: {
199+
resource: this.authorizationResource || 'not provided'
200+
}
201+
});
202+
}
203+
204+
// Check 2: Resource parameter in token request
205+
if (!this.checks.some((c) => c.id === 'resource-parameter-in-token')) {
206+
const hasResource = !!this.tokenResource;
207+
this.checks.push({
208+
id: 'resource-parameter-in-token',
209+
name: 'Resource parameter in token request',
210+
description: hasResource
211+
? 'Client included resource parameter in token request'
212+
: 'Client MUST include resource parameter in token request per RFC 8707',
213+
status: hasResource ? 'SUCCESS' : 'FAILURE',
214+
timestamp,
215+
specReferences: specRefs,
216+
details: {
217+
resource: this.tokenResource || 'not provided'
218+
}
219+
});
220+
}
221+
222+
// Check 3: Resource parameter is valid canonical URI
223+
if (!this.checks.some((c) => c.id === 'resource-parameter-valid-uri')) {
224+
const resourceToValidate =
225+
this.authorizationResource || this.tokenResource;
226+
if (resourceToValidate) {
227+
const validation = this.validateCanonicalUri(resourceToValidate);
228+
this.checks.push({
229+
id: 'resource-parameter-valid-uri',
230+
name: 'Resource parameter is valid canonical URI',
231+
description: validation.valid
232+
? 'Resource parameter is a valid canonical URI (has scheme, no fragment)'
233+
: `Resource parameter is invalid: ${validation.error}`,
234+
status: validation.valid ? 'SUCCESS' : 'FAILURE',
235+
timestamp,
236+
specReferences: specRefs,
237+
details: {
238+
resource: resourceToValidate,
239+
...(validation.error && { error: validation.error })
240+
}
241+
});
242+
}
243+
}
244+
245+
// Check 4: Resource parameter consistency between requests
246+
if (!this.checks.some((c) => c.id === 'resource-parameter-consistency')) {
247+
if (this.authorizationResource && this.tokenResource) {
248+
const consistent = this.authorizationResource === this.tokenResource;
249+
this.checks.push({
250+
id: 'resource-parameter-consistency',
251+
name: 'Resource parameter consistency',
252+
description: consistent
253+
? 'Resource parameter is consistent between authorization and token requests'
254+
: 'Resource parameter MUST be consistent between authorization and token requests',
255+
status: consistent ? 'SUCCESS' : 'FAILURE',
256+
timestamp,
257+
specReferences: specRefs,
258+
details: {
259+
authorizationResource: this.authorizationResource,
260+
tokenResource: this.tokenResource
261+
}
262+
});
263+
}
264+
}
265+
}
266+
267+
private validateCanonicalUri(uri: string): {
268+
valid: boolean;
269+
error?: string;
270+
} {
271+
try {
272+
const parsed = new URL(uri);
273+
// Check for fragment (RFC 8707: MUST NOT include fragment)
274+
if (parsed.hash) {
275+
return {
276+
valid: false,
277+
error: 'contains fragment (not allowed per RFC 8707)'
278+
};
279+
}
280+
return { valid: true };
281+
} catch {
282+
return { valid: false, error: 'invalid URI format' };
283+
}
284+
}
160285
}
161286

162287
export class ClientSecretBasicAuthScenario extends TokenEndpointAuthScenario {

0 commit comments

Comments
 (0)