fix(auth): cover the transport's own discovery, and only rewrite GETs
Two more review findings, both real. The shim wrapped `effectiveAuthFetch` only, but the transport is constructed with the base `this.fetchFn`, and the SDK runs its own discovery from inside the transport on the 401/refresh leg — so a reconnect with an existing auth provider could still hit the upstream failure. It now wraps the base fetch, the same seam `withOAuthEndpointOverrides` uses and the only one that reaches both paths. That trades away the previous claim that a captured Network entry shows the real 404: below the trackers, what they record is the substitution. Rather than leave that silently misleading, a substituted response now carries `x-inspector-oauth-metadata-source` naming the URL its body was actually fetched from — the transport builds its tracker internally, so there is no seam above it to install into instead. An integration test captures the fetch the transport is handed and drives it against a real server, since nothing else proves that wiring. The wrapper also keyed on URL shape alone, but nothing stops an authorization or token endpoint from living under `/.well-known/oauth-authorization-server/`; a POST there returning an ordinary OAuth 4xx would have had its error replaced by a metadata document. Metadata discovery is a GET, so anything else is now left alone. 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:
parent
1db1b9be1f
commit
28c7a9ff8a
6 changed files with 141 additions and 18 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -135,10 +135,15 @@ v2/main/
|
|||
│ │ # one is RFC 8414 metadata that is not a
|
||||
│ │ # valid OIDC document, serves that body as
|
||||
│ │ # the RFC 8414 response so the SDK picks the
|
||||
│ │ # right schema. Sits ABOVE the fetch tracker
|
||||
│ │ # (unlike endpointOverrides, which sits
|
||||
│ │ # below) so the Network tab records the real
|
||||
│ │ # 404 and the real probe — #2172;
|
||||
│ │ # right schema. GET-only, and the well-known
|
||||
│ │ # match requires a `/` boundary, so a token
|
||||
│ │ # endpoint living under that prefix keeps its
|
||||
│ │ # own error. Sits on the BASE fetch like
|
||||
│ │ # endpointOverrides — the one seam that also
|
||||
│ │ # covers the discovery the SDK runs inside the
|
||||
│ │ # transport — so the substituted response
|
||||
│ │ # carries COMPAT_SOURCE_HEADER naming the URL
|
||||
│ │ # its body came from — #2172;
|
||||
│ │ # secret-storage-info.ts browser-safe
|
||||
│ │ # descriptor of WHERE a typed secret lands
|
||||
│ │ # — kind/plaintext/durable plus the label,
|
||||
|
|
|
|||
|
|
@ -453,7 +453,7 @@ The cause is upstream. `discoverAuthorizationServerMetadata` in `@modelcontextpr
|
|||
|
||||
`core/auth/oidcDiscoveryCompat.ts` works around it without fabricating anything. When the RFC 8414 candidate comes back 4xx, it fetches the OIDC candidates the SDK would try next; if one returns a document that satisfies `OAuthMetadataSchema` but *fails* the OIDC schema, that document is returned as the response to the RFC 8414 request — so the SDK validates it under the schema that actually describes it. A genuine OpenID provider document is left alone and takes the SDK's normal OIDC leg. Issuer validation is untouched, since the substituted document is the one the server published, `issuer` included.
|
||||
|
||||
Watch the Network tab to confirm the Inspector is not hiding anything: the real 404 on `/.well-known/oauth-authorization-server` and the real request to `/.well-known/openid-configuration` are both recorded, and the metadata shown in the Auth tab is exactly what the server sent — no invented `jwks_uri`. The wrapper sits above the fetch tracker for that reason.
|
||||
The metadata shown in the Auth tab is exactly what the server sent — no invented `jwks_uri`. In the Network tab the substituted response is captured against the RFC 8414 URL (the wrapper sits on the base fetch, below the tracker, because that is the only seam that also covers the discovery the SDK runs from inside the transport), so it carries an `x-inspector-oauth-metadata-source` response header naming the URL its body was actually fetched from. The same URL is printed as a console warning.
|
||||
|
||||
#### Logging, both eras
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { buildDiscoveryUrls } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
COMPAT_SOURCE_HEADER,
|
||||
oidcDiscoveryCandidates,
|
||||
isRfc8414OnlyMetadata,
|
||||
withRfc8414OidcCompat,
|
||||
|
|
@ -144,11 +145,34 @@ describe("withRfc8414OidcCompat", () => {
|
|||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("application/json");
|
||||
await expect(response.json()).resolves.toEqual(RFC8414_DOC);
|
||||
// The substitution names where its body came from, so a captured Network
|
||||
// entry does not read as a 200 from the RFC 8414 path.
|
||||
expect(response.headers.get(COMPAT_SOURCE_HEADER)).toBe(OIDC_APPENDED);
|
||||
// The failed original, then both OIDC candidates.
|
||||
expect(base).toHaveBeenCalledTimes(3);
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining(OIDC_APPENDED));
|
||||
});
|
||||
|
||||
it("does not touch a non-GET request to a matching path", async () => {
|
||||
// An authorization or token endpoint may legally live under the RFC 8414
|
||||
// well-known prefix; its ordinary OAuth error must reach the caller intact
|
||||
// rather than being replaced with a metadata document (Copilot).
|
||||
const original = new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const base = vi.fn<typeof fetch>().mockResolvedValue(original);
|
||||
const wrapped = withRfc8414OidcCompat(base);
|
||||
|
||||
await expect(
|
||||
wrapped(`${RFC8414_URL}/token`, { method: "post" }),
|
||||
).resolves.toBe(original);
|
||||
await expect(
|
||||
wrapped(new Request(`${RFC8414_URL}/token`, { method: "POST" })),
|
||||
).resolves.toBe(original);
|
||||
expect(base).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("serves an RFC 8414 document found on the path-suffixed OIDC path", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const base = vi.fn<typeof fetch>(async (input) => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ import {
|
|||
createOAuthTestServerConfig,
|
||||
waitForOAuthWellKnown,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
|
||||
import {
|
||||
COMPAT_SOURCE_HEADER,
|
||||
withRfc8414OidcCompat,
|
||||
} from "@inspector/core/auth/oidcDiscoveryCompat.js";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
|
||||
const OIDC_PATH = "/.well-known/openid-configuration";
|
||||
|
||||
|
|
@ -37,7 +42,9 @@ describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
|
|||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
// Metadata routes are served either way; leaving auth unrequired lets
|
||||
// the transport-wiring test below actually connect.
|
||||
requireAuth: false,
|
||||
asMetadataPath: OIDC_PATH,
|
||||
}),
|
||||
});
|
||||
|
|
@ -97,4 +104,43 @@ describe("RFC 8414 metadata at the OIDC well-known path (#2172)", () => {
|
|||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("hands the transport a fetch that carries the shim", async () => {
|
||||
// The SDK also runs discovery from *inside* the transport, which receives
|
||||
// `InspectorClient`'s base fetch directly — so a reconnect with an existing
|
||||
// auth provider must not bypass the shim (Copilot). Capturing the fetch the
|
||||
// transport was handed and driving it against this server is what proves
|
||||
// the wiring, without depending on the SDK to trigger that leg.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
let transportFetch: typeof fetch | undefined;
|
||||
const capturingTransport: typeof createTransportNode = (
|
||||
config,
|
||||
options,
|
||||
) => {
|
||||
transportFetch = options?.fetchFn;
|
||||
return createTransportNode(config, options);
|
||||
};
|
||||
const client = new InspectorClient(
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
{ environment: { transport: capturingTransport } },
|
||||
);
|
||||
try {
|
||||
await client.connect();
|
||||
expect(transportFetch).toBeDefined();
|
||||
|
||||
const response = await transportFetch!(
|
||||
`${serverUrl}/.well-known/oauth-authorization-server`,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get(COMPAT_SOURCE_HEADER)).toBe(
|
||||
`${serverUrl}${OIDC_PATH}`,
|
||||
);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
issuer: serverUrl,
|
||||
});
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,10 +68,18 @@
|
|||
* Inspector would then be displaying, in the Auth and Network tabs, a
|
||||
* metadata document the server never published — which is the one thing a
|
||||
* debugging tool must not do.
|
||||
* - **It does not hide the real traffic.** It is installed *above* the fetch
|
||||
* tracker (`InspectorClient.effectiveAuthFetch`), so the Network tab records
|
||||
* the genuine 404 on the RFC 8414 path and the genuine request to the OIDC
|
||||
* path. The substitution is visible only to the SDK's parse.
|
||||
* - **It does not present the substitution as the server's own answer.** It is
|
||||
* installed on `InspectorClient`'s *base* fetch — below both fetch trackers,
|
||||
* the same seam `withOAuthEndpointOverrides` uses — because that is the only
|
||||
* place that also covers the discovery the SDK runs from **inside the
|
||||
* transport**, which is handed the base fetch directly and whose tracker is
|
||||
* built inside the transport where nothing here can reach above it
|
||||
* (Copilot). One seam therefore covers every path, at the cost that a
|
||||
* captured entry shows the substituted document rather than the real 404 —
|
||||
* so the substituted response carries {@link COMPAT_SOURCE_HEADER} naming
|
||||
* the URL the body actually came from, and the warning below says the same
|
||||
* thing on the console. The probe itself is issued through the wrapped fetch
|
||||
* and so is not separately tracked.
|
||||
*
|
||||
* ## Removing this
|
||||
*
|
||||
|
|
@ -93,6 +101,14 @@ const RFC8414_WELL_KNOWN = "/.well-known/oauth-authorization-server";
|
|||
/** The OpenID Connect Discovery well-known path. */
|
||||
const OIDC_WELL_KNOWN = "/.well-known/openid-configuration";
|
||||
|
||||
/**
|
||||
* Response header stamped on a substituted document, naming the URL the body
|
||||
* was actually fetched from. Inspector-private (`x-inspector-`), never sent on
|
||||
* a request — it exists so a captured Network entry is self-describing rather
|
||||
* than appearing to be a 200 from the RFC 8414 path.
|
||||
*/
|
||||
export const COMPAT_SOURCE_HEADER = "x-inspector-oauth-metadata-source";
|
||||
|
||||
/**
|
||||
* The OIDC discovery candidates the SDK would try after the RFC 8414 candidate
|
||||
* `rfc8414Url` failed, in the SDK's own order.
|
||||
|
|
@ -196,6 +212,21 @@ function discoveryHeaders(
|
|||
return new Headers();
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective HTTP method of a `fetch` call, normalized. `fetch` defaults to
|
||||
* `GET` and treats the method case-insensitively.
|
||||
*/
|
||||
function requestMethodOf(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
): string {
|
||||
if (init?.method) return init.method.toUpperCase();
|
||||
if (typeof input !== "string" && !(input instanceof URL)) {
|
||||
return input.method.toUpperCase();
|
||||
}
|
||||
return "GET";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a `fetch` so a plain OAuth 2.0 authorization server publishing RFC 8414
|
||||
* metadata at `/.well-known/openid-configuration` is discoverable.
|
||||
|
|
@ -209,6 +240,13 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
|||
const response = await fetchFn(input, init);
|
||||
if (response.ok) return response;
|
||||
if (!continuesDiscovery(response.status)) return response;
|
||||
// The URL shape alone does not prove this was a discovery request: nothing
|
||||
// stops an authorization or token endpoint from living under
|
||||
// `/.well-known/oauth-authorization-server/…`, and a `POST` there returning
|
||||
// an ordinary OAuth `400 invalid_grant` must reach its caller intact rather
|
||||
// than being replaced with a metadata document (Copilot). Metadata
|
||||
// discovery is a `GET`, so anything else is not ours.
|
||||
if (requestMethodOf(input, init) !== "GET") return response;
|
||||
|
||||
const candidates = oidcDiscoveryCandidates(requestUrlOf(input));
|
||||
if (candidates.length === 0) return response;
|
||||
|
|
@ -252,7 +290,15 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
|||
return new Response(body, {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
// The substituted response is what the fetch trackers above this
|
||||
// wrapper record, so it says where its body actually came from
|
||||
// rather than letting the Network tab imply the RFC 8414 path
|
||||
// answered. Named on the response, not logged only, so it survives
|
||||
// into the captured entry.
|
||||
[COMPAT_SOURCE_HEADER]: candidate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -795,12 +795,14 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// #2172: recover discovery when a plain OAuth 2.0 authorization server
|
||||
// publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which
|
||||
// the SDK validates as an OpenID provider document and rejects. Wraps the
|
||||
// tracked fetch rather than the base one — the opposite of the overrides
|
||||
// above — so the Network tab records the real 404 and the real probe rather
|
||||
// than the substitution the SDK's parse sees.
|
||||
this.effectiveAuthFetch = withRfc8414OidcCompat(
|
||||
this.buildEffectiveAuthFetch(),
|
||||
);
|
||||
// *base* fetch for the same reason the overrides above do: the SDK also
|
||||
// runs discovery from inside the transport, which is handed `this.fetchFn`
|
||||
// directly, so a reconnect with an existing auth provider would otherwise
|
||||
// still hit the upstream failure. The substituted response is stamped with
|
||||
// `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came
|
||||
// from rather than appearing to be a 200 from the RFC 8414 path.
|
||||
this.fetchFn = withRfc8414OidcCompat(this.fetchFn);
|
||||
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
|
||||
|
||||
this.sessionId = options.sessionId;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue