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
73 changes: 73 additions & 0 deletions examples/clients/typescript/auth-test-resource-slash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env node

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { withOAuthRetryWithProvider } from './helpers/withOAuthRetry';
import { ConformanceOAuthProvider } from './helpers/ConformanceOAuthProvider';
import { runAsCli } from './helpers/cliRunner';
import { logger } from './helpers/logger';

/**
* Broken client that re-serializes the protected resource metadata's
* `resource` through a URL parser before sending it as the RFC 8707
* `resource` parameter.
*
* BUG: `new URL('https://example.com').href` is `https://example.com/`, so a
* pathless resource identifier gains a trailing slash and no longer matches
* the value the server published. Authorization servers that compare the
* indicator exactly (Microsoft Entra ID, AADSTS9010010) reject the request.
* This is the shape of typescript-sdk#1968 and python-sdk#2578.
*/
class SlashAppendingResourceProvider extends ConformanceOAuthProvider {
async validateResourceURL(
_serverUrl: string | URL,
resource?: string
): Promise<URL | undefined> {
if (!resource) {
return undefined;
}
const url = new URL(resource);
// BUG: always emit the normalized form with a trailing slash, whatever
// the server published.
if (!url.pathname.endsWith('/')) {
url.pathname = `${url.pathname}/`;
}
return url;
}
}

export async function runClient(serverUrl: string): Promise<void> {
const provider = new SlashAppendingResourceProvider(
'http://localhost:3000/callback',
{
client_name: 'test-auth-client-resource-slash',
redirect_uris: ['http://localhost:3000/callback'],
application_type: 'native'
}
);

const client = new Client(
{ name: 'test-auth-client-resource-slash', version: '1.0.0' },
{ capabilities: {} }
);

const oauthFetch = withOAuthRetryWithProvider(
provider,
new URL(serverUrl)
)(fetch);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});

await client.connect(transport);
logger.debug('✅ Successfully connected to MCP server');

await client.listTools();
logger.debug('✅ Successfully listed tools');

await transport.close();
logger.debug('✅ Connection closed successfully');
}

runAsCli(runClient, import.meta.url, 'auth-test-resource-slash <server-url>');
34 changes: 32 additions & 2 deletions src/scenarios/client/auth/discovery-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ScenarioUrls } from '../../../types';
import { createAuthServer } from './helpers/createAuthServer';
import { createServer } from './helpers/createServer';
import { ServerLifecycle } from './helpers/serverLifecycle';
import { addResourceParameterChecks } from './helpers/resourceParameterChecks';
import { SpecReferences } from './spec-references';
import { Request, Response } from 'express';

Expand Down Expand Up @@ -77,6 +78,13 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {
const server = new ServerLifecycle();
let checks: ConformanceCheck[] = [];

// Track resource parameters for RFC 8707 validation. metadata-var2 serves
// the PRM at the root, so its `resource` is a bare origin: the case a URL
// parser rewrites with a trailing slash.
let authorizationResource: string | undefined;
let tokenResource: string | undefined;
let prmResource: string | undefined;

const routePrefix = config.authRoutePrefix || '';
const isOpenIdConfiguration = config.oauthMetadataLocation.includes(
'openid-configuration'
Expand All @@ -97,11 +105,23 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {

async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
checks = [];
authorizationResource = undefined;
tokenResource = undefined;
prmResource = undefined;

const authApp = createAuthServer(ctx, checks, authServer.getUrl, {
metadataPath: config.oauthMetadataLocation,
isOpenIdConfiguration,
...(routePrefix && { routePrefix })
...(routePrefix && { routePrefix }),
onAuthorizationRequest: ({ resource }) => {
authorizationResource = resource;
},
onTokenRequest: ({ body }) => {
tokenResource = body.resource;
// Same token the auth server mints by default; these scenarios
// request no scopes.
return { token: `test-token-${Date.now()}`, scopes: [] };
}
});

// If path-based OAuth metadata, trap root requests
Expand Down Expand Up @@ -134,7 +154,10 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {

const app = createServer(ctx, checks, server.getUrl, getAuthServerUrl, {
prmPath: config.prmLocation,
includePrmInWwwAuth: config.inWwwAuth
includePrmInWwwAuth: config.inWwwAuth,
onPrmRequest: ({ resource }) => {
prmResource = resource;
}
});

// Add trap for root PRM requests if configured
Expand Down Expand Up @@ -198,6 +221,13 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario {
}
}

// RFC 8707 Resource Parameter Validation Checks
addResourceParameterChecks(
checks,
{ authorizationResource, tokenResource, prmResource },
new Date().toISOString()
);

return checks;
}
};
Expand Down
7 changes: 6 additions & 1 deletion src/scenarios/client/auth/helpers/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface ServerOptions {
tokenVerifier?: MockTokenVerifier;
/** Override the resource field in PRM response (for testing resource mismatch) */
prmResourceOverride?: string;
/** Observe the `resource` identifier the PRM route served (RFC 8707 checks) */
onPrmRequest?: (requestData: { resource: string; timestamp: string }) => void;
}

export function createServer(
Expand All @@ -46,7 +48,8 @@ export function createServer(
includePrmInWwwAuth = true,
includeScopeInWwwAuth = false,
tokenVerifier,
prmResourceOverride
prmResourceOverride,
onPrmRequest
} = options;
// Factory: create a fresh Server per request to avoid "Already connected" errors
// after the v1.26.0 security fix (GHSA-345p-7cg4-v4c7)
Expand Down Expand Up @@ -130,6 +133,8 @@ export function createServer(
? getBaseUrl()
: `${getBaseUrl()}/mcp`);

onPrmRequest?.({ resource, timestamp: new Date().toISOString() });

const prmResponse: any = {
resource,
authorization_servers: [getAuthServerUrl()]
Expand Down
178 changes: 178 additions & 0 deletions src/scenarios/client/auth/helpers/resourceParameterChecks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { ConformanceCheck } from '../../../../types';
import { SpecReferences } from '../spec-references';

/**
* What a scenario's mock servers observed about the RFC 8707 `resource`
* parameter: the values the client sent to the authorization and token
* endpoints, and the identifier the protected resource metadata served.
*/
export interface ResourceParameterObservation {
/** `resource` query parameter the client sent to the authorization endpoint. */
authorizationResource?: string;
/** `resource` form parameter the client sent to the token endpoint. */
tokenResource?: string;
/** `resource` value the protected resource metadata document served. */
prmResource?: string;
}

/**
* RFC 8707 resource-parameter checks, shared by every client-auth scenario
* whose mock servers observe the authorization and token requests. The check
* IDs are stable across scenarios so one slug finds every emission.
*
* Each check is emitted once per scenario run; a check already present under
* the same id is left alone.
*/
export function addResourceParameterChecks(
checks: ConformanceCheck[],
observed: ResourceParameterObservation,
timestamp: string
): void {
const { authorizationResource, tokenResource, prmResource } = observed;
const specRefs = [
SpecReferences.RFC_8707_RESOURCE_INDICATORS,
SpecReferences.MCP_RESOURCE_PARAMETER
];

// Check 1: Resource parameter in authorization request
if (!checks.some((c) => c.id === 'resource-parameter-in-authorization')) {
const hasResource = !!authorizationResource;
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: authorizationResource || 'not provided'
}
});
}

// Check 2: Resource parameter in token request
if (!checks.some((c) => c.id === 'resource-parameter-in-token')) {
const hasResource = !!tokenResource;
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: tokenResource || 'not provided'
}
});
}

// Check 3: Resource parameter is valid canonical URI
if (!checks.some((c) => c.id === 'resource-parameter-valid-uri')) {
const resourceToValidate = authorizationResource || tokenResource;
if (resourceToValidate) {
const validation = validateCanonicalUri(resourceToValidate);
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 (!checks.some((c) => c.id === 'resource-parameter-consistency')) {
if (authorizationResource && tokenResource) {
const consistent = authorizationResource === tokenResource;
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,
tokenResource
}
});
}
}

// Check 5: Resource parameter equals the identifier published in PRM.
// The MCP spec requires the canonical URI, which it defines as the RFC 9728
// `resource` value; RFC 9728 §3.3 requires that value to be identical to the
// identifier the client used. Re-serializing it through a URL parser is the
// usual way to break this: `new URL('https://example.com').href` is
// `https://example.com/`, and authorization servers that match the
// indicator exactly reject the extra slash.
if (!checks.some((c) => c.id === 'resource-parameter-matches-prm')) {
const sent: Array<[request: string, value: string]> = [];
if (authorizationResource !== undefined) {
sent.push(['authorization', authorizationResource]);
}
if (tokenResource !== undefined) {
sent.push(['token', tokenResource]);
}
if (prmResource !== undefined && sent.length > 0) {
const mismatched = sent
.filter(([, value]) => value !== prmResource)
.map(([request]) => request);
const matches = mismatched.length === 0;
const errorMessage = `Client MUST send the resource identifier exactly as published in protected resource metadata; the ${mismatched.join(' and ')} request sent a different value (a URL parser that appends "/" to a pathless identifier is the usual cause)`;
checks.push({
id: 'resource-parameter-matches-prm',
name: 'Resource parameter matches protected resource metadata',
description: matches
? 'Client sent the resource identifier exactly as published in protected resource metadata'
: errorMessage,
status: matches ? 'SUCCESS' : 'FAILURE',
timestamp,
specReferences: [
...specRefs,
SpecReferences.MCP_CANONICAL_SERVER_URI,
SpecReferences.RFC_9728_RESOURCE_IDENTITY
],
...(!matches && { errorMessage }),
details: {
prmResource,
authorizationResource: authorizationResource ?? 'not provided',
tokenResource: tokenResource ?? 'not provided'
}
});
}
}
}

function 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' };
}
}
Loading
Loading