fix(auth): pass the challenge's resource_metadata URL to SDK auth()
An RFC 9728 `WWW-Authenticate: Bearer resource_metadata="…"` challenge was parsed and then dropped by the normalized `AuthChallenge`, so every challenge-driven `mcpAuth()` call ran discovery without it and probed locations derived from the MCP server URL instead. OAuth failed outright against any server whose metadata document lives off the well-known path. The SDK handles this correctly inside its own transports, but the Inspector never reaches that path — it intercepts the 401 and drives `auth()` itself to capture the authorization URL and run the step-up scope union. Carry the advertised URL on `AuthChallenge` as a string (it crosses the web client's remote-backend boundary as JSON), convert it at the OAuth boundary, and pass it to the standard challenge leg, the forced step-up leg, and CIMD pre-registration — which runs before `auth()` and did its own discovery. A malformed value is ignored rather than surfaced, matching the SDK's parser. The callback leg needs nothing: SDK `auth()` persists the URL in its discovery state. Adds an `oauth.resourceMetadataPath` test-server mode that moves the document off the well-known path entirely, so the integration test proves discovery genuinely fails without the fix. Closes #2071 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
fd1fa06cf5
commit
f8a46726b7
1 changed files with 469 additions and 6 deletions
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* RFC 9728 `resource_metadata` end-to-end, against a real OAuth test server
|
||||
* whose protected-resource metadata document lives at a non-default path and
|
||||
* whose well-known locations deliberately 404 (#2071).
|
||||
*
|
||||
* The unit tests prove the URL is parsed and handed to `mcpAuth()`. This file
|
||||
* proves the two halves that only a real server can: that the challenge the
|
||||
* server actually emits parses into `AuthChallenge.resourceMetadataUrl`, and
|
||||
* that SDK discovery genuinely cannot find the document without it — so the
|
||||
* plumbing is load-bearing rather than merely present.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
TestServerHttp,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
waitForOAuthWellKnown,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import {
|
||||
challengeResourceMetadataUrl,
|
||||
parseAuthChallengeFromResponse,
|
||||
} from "@inspector/core/auth/challenge.js";
|
||||
|
||||
const METADATA_PATH = "/custom/protected-resource";
|
||||
|
||||
describe("OAuth challenge resource_metadata (RFC 9728)", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
let serverUrl = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
mcpServer = new TestServerHttp({
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
resourceMetadataPath: METADATA_PATH,
|
||||
}),
|
||||
});
|
||||
const port = await mcpServer.start();
|
||||
serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mcpServer?.stop();
|
||||
mcpServer = null;
|
||||
}, 30_000);
|
||||
|
||||
it("advertises the non-default metadata URL on the 401 challenge", async () => {
|
||||
const response = await fetch(`${serverUrl}/mcp`, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const challenge = parseAuthChallengeFromResponse(response);
|
||||
expect(challenge?.resourceMetadataUrl).toBe(`${serverUrl}${METADATA_PATH}`);
|
||||
expect(challengeResourceMetadataUrl(challenge!)?.href).toBe(
|
||||
`${serverUrl}${METADATA_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the metadata document only from the advertised path", async () => {
|
||||
const advertised = await fetch(`${serverUrl}${METADATA_PATH}`);
|
||||
expect(advertised.status).toBe(200);
|
||||
await expect(advertised.json()).resolves.toMatchObject({
|
||||
authorization_servers: [serverUrl],
|
||||
});
|
||||
|
||||
const wellKnown = await fetch(
|
||||
`${serverUrl}/.well-known/oauth-protected-resource`,
|
||||
);
|
||||
expect(wellKnown.status).toBe(404);
|
||||
});
|
||||
|
||||
it("SDK discovery resolves with the advertised URL and fails without it", async () => {
|
||||
await expect(
|
||||
discoverOAuthProtectedResourceMetadata(serverUrl, {
|
||||
resourceMetadataUrl: new URL(`${serverUrl}${METADATA_PATH}`),
|
||||
}),
|
||||
).resolves.toMatchObject({ authorization_servers: [serverUrl] });
|
||||
|
||||
// The pre-fix behavior: discovery derived from the MCP server URL alone.
|
||||
await expect(
|
||||
discoverOAuthProtectedResourceMetadata(serverUrl),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("keeps the showcase config valid and carrying the custom path", () => {
|
||||
const configPath = fileURLToPath(
|
||||
new URL(
|
||||
"../../../../../../test-servers/configs/oauth-custom-resource-metadata-http.json",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
// loadConfig validates as it parses, so this is the regression test for
|
||||
// the checked-in showcase config as much as for the new field.
|
||||
expect(resolveConfig(loadConfig(configPath)).oauth).toMatchObject({
|
||||
enabled: true,
|
||||
resourceMetadataPath: METADATA_PATH,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue