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:
parent
18bda28ce1
commit
a8cbc506ab
3 changed files with 111 additions and 7 deletions
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue