fix: address Copilot review round 1 on #2306

Validate `oauth.clientMetadataPath` the way the other two metadata paths
are validated — in `loadConfig` for the JSON route and again at server
setup for a `ServerConfig` built in code. This path is not merely
advertised: it becomes the served document's own `client_id`, so an
off-origin or query-bearing value would publish a client id this server
cannot honour.

Derive that `client_id` from `req.originalUrl` rather than from the
registered route, so a document fetched with a query string answers with
a `client_id` byte-identical to the URL it was fetched from — the
equality CIMD turns on.

Correct the docs' fixed-port warning: this fixture configures no
`issuerUrl` and derives the `client_id` from the request, which is
exactly why the integration test can drive it on a harness-chosen port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGH2ytPzSPxnp4cibaV3Z
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-10 22:49:36 -04:00
commit a8cbc506ab
3 changed files with 111 additions and 7 deletions

View file

@ -759,7 +759,16 @@ export interface ServerConfig {
scope?: string;
};
/** Where to serve `clientMetadata` (default `/client-metadata.json`). */
/**
* Where to serve `clientMetadata` (default `/client-metadata.json`).
*
* Must be origin-relative with no query or fragment, and is validated as
* such — both by `loadConfig` and again at server setup for a config built
* in code. Unlike the other metadata paths this one is not merely
* advertised: it becomes the document's own `client_id`, so an off-origin
* or query-bearing value would publish a client id this server cannot
* honour (Copilot).
*/
clientMetadataPath?: string;
/**

View file

@ -48,6 +48,11 @@ export interface ConfigFileOAuth {
clientName?: string;
scope?: string;
};
/**
* Where to serve `clientMetadata` (default `/client-metadata.json`);
* validated as an origin-relative path, since it becomes the document's own
* `client_id`. See `composable-test-server.ts`.
*/
clientMetadataPath?: string;
tokenExpirationSeconds?: number;
supportRefreshTokens?: boolean;
@ -260,6 +265,12 @@ function validateConfig(
`Invalid config in ${filePath}: oauth.asMetadataPath must be an origin-relative path (e.g. "/.well-known/openid-configuration") — a value such as "//host/doc" would move the document off this server entirely`,
);
}
const cimdPath = oauth.clientMetadataPath;
if (cimdPath !== undefined && !isOriginRelativePath(cimdPath)) {
throw new Error(
`Invalid config in ${filePath}: oauth.clientMetadataPath must be an origin-relative path (e.g. "/client-metadata.json") — this path becomes the document's own client_id, so a value such as "//host/doc" would publish a client id naming a host this server does not serve`,
);
}
if (transportType === "stdio" && oauth.enabled === true) {
throw new Error(
`Invalid config in ${filePath}: oauth requires streamable-http or sse transport`,

View file

@ -101,6 +101,27 @@ function asMetadataPath(config: OAuthConfig): string {
return path;
}
/**
* Where the CIMD client metadata document is served from, validated the same
* way the two metadata paths above are — and for a sharper reason than either.
* This path is not merely advertised: it becomes the document's own
* `client_id`, so a value such as `//other-host/doc` would publish a client id
* naming a host this server does not control, and `/doc?version=1` would
* publish one that cannot reach the route Express registered (Copilot).
*/
function clientMetadataPath(config: OAuthConfig): string {
const path = config.clientMetadataPath;
if (path === undefined) {
return "/client-metadata.json";
}
if (!isOriginRelativePath(path)) {
throw new Error(
`oauth.clientMetadataPath must be an origin-relative path (got ${JSON.stringify(path)})`,
);
}
return path;
}
/**
* The `WWW-Authenticate` challenge sent with every 401.
*
@ -294,15 +315,21 @@ function setupMetadataEndpoints(
// nothing.
if (config.supportCIMD && config.clientMetadata) {
const doc = config.clientMetadata;
const metadataPath = config.clientMetadataPath ?? "/client-metadata.json";
const metadataPath = clientMetadataPath(config);
app.get(metadataPath, (req: Request, res: Response) => {
// Derived from the request rather than from `issuerUrl`, so the
// document's own `client_id` always equals the URL it was fetched from
// — which is what CIMD requires, and what stays true if the server
// walked to another port on EADDRINUSE.
//
// `originalUrl` rather than the registered route, so a client id that
// carries a query string (`/client-metadata.json?profile=a`) still gets
// a document whose `client_id` is byte-identical to the URL that was
// fetched. Answering with the bare route instead would hand back a
// document that fails the very equality CIMD turns on (Copilot).
const requestBaseUrl = `${req.protocol}://${req.get("host")}`;
res.json({
client_id: new URL(metadataPath, requestBaseUrl).href,
client_id: new URL(req.originalUrl, requestBaseUrl).href,
client_name: doc.clientName ?? "MCP Inspector (CIMD test fixture)",
redirect_uris: doc.redirectUris,
// CIMD clients are public and authenticate with nothing; the server's