fix(auth): accept RFC 8414 metadata served at the OIDC well-known path

The SDK's `discoverAuthorizationServerMetadata` picks its validation schema
from the well-known filename that resolved rather than from the document that
came back, so anything found at `/.well-known/openid-configuration` is
validated as an OpenID provider document — requiring `jwks_uri`,
`subject_types_supported` and `id_token_signing_alg_values_supported`, three
fields RFC 8414 does not define. RFC 8414 §5 permits that filename for general
OAuth metadata, so a conforming plain OAuth 2.0 authorization server is
rejected; and because the parse throws instead of continuing the candidate
loop, discovery aborts and the connection fails outright.

Filed upstream as modelcontextprotocol/typescript-sdk#2733.

`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating a
field: on a failed RFC 8414 candidate it probes the OIDC candidates the SDK
would try next and, when one returns a document that satisfies
`OAuthMetadataSchema` but fails the OIDC schema, serves that body as the RFC
8414 response so the SDK picks the schema that describes it. A genuine OpenID
provider document is left to the SDK's own OIDC leg. Issuer validation runs
unchanged, since the substituted document is the one the server published.

It wraps `effectiveAuthFetch` — above the fetch tracker, the opposite of
`withOAuthEndpointOverrides` — so the Network tab still records the real 404
and the real probe rather than the substitution.

The candidate derivation mirrors the SDK's `buildDiscoveryUrls`, which a fetch
wrapper cannot call, and a test pins it against that export so the two cannot
drift.

Adds `oauth.asMetadataPath` to the composable test server (mirroring
`oauth.resourceMetadataPath`) and the `oauth-rfc8414-at-oidc-path-http.json`
showcase config that reproduces the failure.

Closes #2172

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 00:40:11 -04:00
commit 7ca753bc4c

View file

@ -0,0 +1,100 @@
/**
* RFC 8414 authorization-server metadata served at the OpenID Connect
* well-known path, end to end against a real OAuth test server (#2172).
*
* The unit tests prove the wrapper substitutes the right document. This file
* proves the two halves only a real server can: that the SDK genuinely rejects
* a conforming plain-OAuth document found at `openid-configuration`, and that
* the wrapper recovers discovery against the same server without adding a
* field the server never published.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
import {
TestServerHttp,
getDefaultServerConfig,
createOAuthTestServerConfig,
waitForOAuthWellKnown,
} from "@modelcontextprotocol/inspector-test-server";
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
const OIDC_PATH = "/.well-known/openid-configuration";
/** The three fields OpenID Connect Discovery requires and RFC 8414 does not. */
const OIDC_ONLY_FIELDS = [
"jwks_uri",
"subject_types_supported",
"id_token_signing_alg_values_supported",
] as const;
describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
let mcpServer: TestServerHttp | null = null;
let serverUrl = "";
beforeAll(async () => {
mcpServer = new TestServerHttp({
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createOAuthTestServerConfig({
requireAuth: true,
asMetadataPath: OIDC_PATH,
}),
});
const port = await mcpServer.start();
serverUrl = `http://localhost:${port}`;
await waitForOAuthWellKnown(serverUrl, { metadataPath: OIDC_PATH });
}, 30_000);
afterAll(async () => {
await mcpServer?.stop();
mcpServer = null;
}, 30_000);
it("serves plain RFC 8414 metadata only from the OIDC path", async () => {
const oidc = await fetch(`${serverUrl}${OIDC_PATH}`);
expect(oidc.status).toBe(200);
const metadata = (await oidc.json()) as Record<string, unknown>;
expect(metadata.issuer).toBe(serverUrl);
for (const field of OIDC_ONLY_FIELDS) {
expect(metadata).not.toHaveProperty(field);
}
const rfc8414 = await fetch(
`${serverUrl}/.well-known/oauth-authorization-server`,
);
expect(rfc8414.status).toBe(404);
});
it("is rejected by unwrapped SDK discovery", async () => {
// The upstream defect this module exists for
// (modelcontextprotocol/typescript-sdk#2733). If this ever starts
// resolving, the SDK has been fixed and `oidcDiscoveryCompat` can go.
await expect(
discoverAuthorizationServerMetadata(serverUrl),
).rejects.toThrow(/jwks_uri/);
});
it("resolves through the compat wrapper without fabricating a field", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const metadata = await discoverAuthorizationServerMetadata(serverUrl, {
fetchFn: withRfc8414OidcCompat(fetch),
});
expect(metadata?.issuer).toBe(serverUrl);
expect(metadata?.authorization_endpoint).toBe(
`${serverUrl}/oauth/authorize`,
);
expect(metadata?.token_endpoint).toBe(`${serverUrl}/oauth/token`);
for (const field of OIDC_ONLY_FIELDS) {
expect(metadata).not.toHaveProperty(field);
}
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(`${serverUrl}${OIDC_PATH}`),
);
} finally {
warn.mockRestore();
}
});
});