fix(auth): accept RFC 8414 metadata served at the OIDC well-known path - #2184
fix(auth): accept RFC 8414 metadata served at the OIDC well-known path#2184cliffhall wants to merge 5 commits into
Conversation
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>
There was a problem hiding this comment.
Pull request overview
Adds an OAuth discovery compatibility shim for RFC 8414 metadata served from an OIDC well-known path.
Changes:
- Adds and wires the discovery compatibility wrapper.
- Extends OAuth test-server configuration and fixtures.
- Adds unit/integration tests and documentation.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
core/auth/oidcDiscoveryCompat.ts |
Implements the compatibility wrapper. |
core/auth/index.ts |
Exports compatibility helpers. |
core/mcp/inspectorClient.ts |
Applies the wrapper to authentication fetches. |
test-servers/src/test-server-oauth.ts |
Supports custom AS metadata paths. |
test-servers/src/test-server-fixtures.ts |
Exposes the fixture option. |
test-servers/src/test-helpers.ts |
Supports polling custom metadata paths. |
test-servers/src/load-config.ts |
Parses and validates the option. |
test-servers/src/composable-test-server.ts |
Documents the server configuration field. |
test-servers/configs/oauth-rfc8414-at-oidc-path-http.json |
Adds a reproduction server. |
clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts |
Tests compatibility behavior and edge cases. |
clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts |
Verifies discovery against a real server. |
README.md |
Documents the showcase and workaround. |
AGENTS.md |
Records the new authentication component. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| this.effectiveAuthFetch = withRfc8414OidcCompat( | ||
| this.buildEffectiveAuthFetch(), | ||
| ); |
There was a problem hiding this comment.
Fixed in 1db1b9b. You're right — refreshStoredAuthToken calls SDK discovery directly rather than through InspectorClient.effectiveAuthFetch, so a state file with a refresh token and client information but no persisted serverMetadata still hit the upstream failure and could not refresh against exactly the servers this PR is for.
Its default discover now wraps the global fetch with withRfc8414OidcCompat. A caller-supplied options.fetchFn is left alone — it is theirs to compose — and the candidate walker passes no options, so in practice this wraps the global.
Covered by a new test in clients/cli/__tests__/stored-auth.test.ts that injects no discover: the wrapper lives in the default, so injecting one would bypass the thing under test. It stands up a real server that 404s /.well-known/oauth-authorization-server/mcp and serves plain RFC 8414 metadata (no jwks_uri) at /mcp/.well-known/openid-configuration, then asserts the refresh completes and that the RFC 8414 location really was probed — so the document could only have come from the OIDC path.
| if (!url.pathname.startsWith(RFC8414_WELL_KNOWN)) return []; | ||
| const path = url.pathname.slice(RFC8414_WELL_KNOWN.length); |
There was a problem hiding this comment.
Fixed in 1db1b9b. Agreed, and the consequence you name is the serious half: this wrapper is on the general auth fetch, so a bare prefix match meant a failed request to a path like /.well-known/oauth-authorization-server-backup could have its real response replaced by a document fetched from a URL derived from it.
oidcDiscoveryCandidates now requires the remainder to be empty or to start with /. That is exactly the set buildDiscoveryUrls can emit — it appends the authorization server's pathname, which always begins with / — so nothing legitimate is lost. Added a unit test asserting the -backup path yields no candidates.
Two review findings. The stored-token refresh path (`refreshStoredAuthToken`) calls SDK discovery directly rather than through `InspectorClient.effectiveAuthFetch`, so a state file holding a refresh token and client information but no `serverMetadata` still hit the upstream OIDC-schema failure and could not refresh against the very servers this change is for. Its default `discover` now wraps the global fetch with the compat shim; a caller-supplied `fetchFn` is left alone. Covered by a test that injects no `discover` — injecting one would bypass the default under test — against a real server that 404s the RFC 8414 path and serves plain RFC 8414 metadata at the appended OIDC path. `oidcDiscoveryCandidates` matched the well-known path by bare prefix, so it also claimed `/.well-known/oauth-authorization-server-backup` and would have replaced that path's failed response with a document fetched from a derived URL. It now requires the exact path or a `/` boundary, which is precisely the set `buildDiscoveryUrls` can emit. 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>
Review round 1 — both comments addressed (1db1b9b)Mirroring the inline replies here, since they go outdated once the fix is pushed. 1. New test in 2.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
core/mcp/inspectorClient.ts:803
- This only wraps
effectiveAuthFetch, but the transport is still constructed withthis.fetchFn(core/mcp/inspectorClient.ts:2033-2037). The SDK's SSE/Streamable HTTP transports pass that transport fetch into their internal OAuthauth()/onUnauthorizedflow, so a Web/TUI reconnect with an existing auth provider—especially stored tokens withoutserverMetadata—can still run discovery without this compatibility shim and hit the same OIDC-schema failure. The adjacent comment at lines 784-790 already calls out this transport-owned discovery path for endpoint overrides. Please compose the shim into that path as well while preserving transport request tracking, and cover a transport-triggered discovery case.
this.effectiveAuthFetch = withRfc8414OidcCompat(
this.buildEffectiveAuthFetch(),
);
| const candidates = oidcDiscoveryCandidates(requestUrlOf(input)); | ||
| if (candidates.length === 0) return response; |
There was a problem hiding this comment.
Fixed in 28c7a9f. Agreed — nothing stops an authorization or token endpoint from living under /.well-known/oauth-authorization-server/…, and replacing its ordinary OAuth 4xx with a metadata document would mask the real failure.
The wrapper now checks the effective method (init.method, else the Request's, else GET, upper-cased since fetch treats it case-insensitively) and returns the response untouched for anything that is not a GET. Regression test covers both forms — { method: "post" } on a string URL and a Request constructed with POST — against a 400 invalid_grant on a matching path, asserting the original response object comes back and no probe was made.
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>
Review round 2 — both comments addressed (28c7a9f)1. 2. Suppressed comment — the transport is still constructed with That does cost the property the previous placement bought, and I'd rather state it than let it read as free: below the trackers, what a captured Network entry records is the substitution, not the real 404. The transport builds its tracker internally, so there is no seam above it to install into instead. Rather than leave that silently misleading, a substituted response now carries an For coverage of that path,
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
core/auth/oidcDiscoveryCompat.ts:263
- This catches every probe failure and then may substitute a later candidate, but the SDK only swallows browser CORS
TypeErrors; in Node it propagates networkTypeErrors, and it always propagates non-TypeErrorexceptions. Thus a failing first OIDC candidate can be silently bypassed by the shim even though normal SDK discovery would stop there. Apply the SDK's CORS retry/error policy before deciding whether to continue.
This issue also appears in the following locations of the same file:
- line 265
- line 266
- line 270
} catch {
// A network or CORS failure on a URL the SDK had not asked for yet is
// not this wrapper's to report — the SDK will make the same request and
// handle it. Try the next candidate.
continue;
core/auth/oidcDiscoveryCompat.ts:265
- The SDK advances to the next discovery candidate only for 4xx responses and 502; other statuses such as 500/503 terminate discovery. This unconditional
continuecan therefore hide an outage on the first OIDC candidate and substitute RFC 8414 metadata from the second candidate, turning a failure into success. Mirror the SDK's status policy here so non-continuing responses are left for the SDK to surface.
if (!probe.ok) continue;
core/auth/oidcDiscoveryCompat.ts:266
- The SDK does not gate metadata parsing on
Content-Type; a successful first OIDC response is parsed immediately. Continuing here can skip a genuine OIDC document served with a nonstandard/missing media type and substitute a later RFC-only document, changing a case the SDK currently accepts. Return the original RFC response so the SDK processes this first OIDC candidate itself.
if (!isJsonDocumentResponse(probe.headers.get("content-type"))) continue;
core/auth/oidcDiscoveryCompat.ts:274
- A 2xx OIDC candidate with malformed JSON is terminal in the SDK because
response.json()throws. Continuing to a lower-priority candidate can instead make discovery succeed and hide the malformed higher-priority endpoint. Return the original RFC response here so the SDK replays and surfaces its normal parse failure.
try {
body = await probe.text();
parsed = JSON.parse(body);
} catch {
continue;
| // 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); |
There was a problem hiding this comment.
Fixed the description in the same pass as 89d6af4. You're right that it still described the previous placement — the shim moved onto the base fetch (below both trackers) in 28c7a9f so it could also cover the transport-owned discovery leg, and the PR body had not caught up.
It now says what the implementation does: a captured entry shows the substituted document rather than the real 404, which is why the substituted response carries x-inspector-oauth-metadata-source naming the URL its body was actually fetched from, and why the probe is not separately tracked. The module docstring, the code comment, and the README section were already updated in 28c7a9f.
The probe loop advanced to the next OIDC candidate on any failure, which meant it could promote a later candidate over an earlier one the SDK would have stopped at — turning a failure into a success, a worse defect than the one being worked around. Four distinct cases: a 500 (an outage the SDK surfaces), a 2xx body that will not parse (terminal for the SDK), a network error (which the SDK propagates outside the browser's CORS case), and a non-JSON media type. It now continues only where the SDK's own loop does — a 4xx or 502 — and otherwise returns the original response, leaving the SDK to make the same request and reach its own verdict. The `content-type` gate is gone entirely, because the SDK parses a 2xx discovery body whatever media type it carries, so gating could skip a genuine OpenID provider document and substitute an RFC-8414-only one from a lower-priority candidate in its place. 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>
Review round 3 — addressed (89d6af4, plus a description fix)Inline: the PR description claimed traffic visibility the implementation no longer provides. Correct — the shim moved onto the base fetch in 28c7a9f so it could cover the transport-owned discovery leg, and the description still described the old placement. Rewritten to say what it actually does: a captured entry shows the substituted document, which is why it carries Suppressed (4) — the probe loop was more permissive than the SDK's candidate policy. These are four faces of one real defect and I've taken all of them. Advancing on any probe failure meant the shim could promote a later candidate over an earlier one the SDK would have stopped at — turning a failure into a success, which is worse than the bug being worked around:
The rule is now stated once, in a comment on the loop: continue only where the SDK's own loop continues (4xx or 502); anywhere else, hand back the original response. Five tests pin the individual branches, including a new one asserting a 500 on the first candidate is not papered over by a valid document on the second.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
core/auth/oidcDiscoveryCompat.ts:302
- This records the requested candidate, not necessarily the URL that supplied the body. Since
fetchfollows redirects, a redirected metadata endpoint makes this diagnostic claim the wrong source; use the finalprobe.url, with the candidate as a fallback for synthetic/custom responses.
[COMPAT_SOURCE_HEADER]: candidate,
core/auth/oidcDiscoveryCompat.ts:286
- The warning has the same redirect ambiguity as the response header: after a followed redirect,
candidateis not where the body came from. Reportprobe.urlwhen available so console diagnostics identify the actual metadata source.
`[oauth] ${candidate} returned RFC 8414 OAuth 2.0 authorization server ` +
| if (!probe.ok) { | ||
| if (continuesDiscovery(probe.status)) continue; | ||
| return response; | ||
| } |
There was a problem hiding this comment.
Fixed in 039fc6f — and the second half of it (the discarded original) too. A skipped probe and the failed RFC 8414 response the substitution replaces are both cancelled now, via a small releaseBody helper following the authChallengeFetch.ts precedent you pointed at. Test asserts it: three streamed bodies with cancel callbacks, and the 404 probe plus the original are the two that get released.
| `(modelcontextprotocol/typescript-sdk#2733), so the Inspector is ` + | ||
| `handing it to discovery as the RFC 8414 document it is.`, | ||
| ); | ||
| return new Response(body, { |
There was a problem hiding this comment.
Fixed in 039fc6f — see the reply on the sibling comment; both the skipped probe and this discarded original are released now.
| // wrapper — otherwise a stored refresh token with no persisted | ||
| // `serverMetadata` still cannot refresh against an authorization server that | ||
| // publishes RFC 8414 metadata at the OIDC well-known path (Copilot). | ||
| const compatFetch = withRfc8414OidcCompat(fetch); |
There was a problem hiding this comment.
Fixed in 039fc6f, both legs as you suggested. refreshStoredAuthToken runs outside InspectorClient, so nothing else puts a proxy under it — it now builds one withRfc8414OidcCompat(createProxyFetch() ?? fetch) and passes it to the discovery probe and to refreshAuthorization, so the token request does not bypass the proxy either. (That second one was a pre-existing gap rather than something this PR introduced, but it is the same fetch and the same function, so fixing it separately would have been artificial.)
…ce, proxy the CLI refresh Three review findings. Probe responses the loop skips — and the original failed response the substitution replaces — were dropped without releasing their bodies. On Node/undici that holds the connection, and this loop can run on every OAuth attempt, so repeated discovery against a 4xx candidate could exhaust the origin's pool. Both paths now cancel, the same discipline `core/mcp/node/authChallengeFetch.ts` uses for a discarded 401. `fetch` follows redirects, so the candidate URL is where the probe was aimed, not necessarily where the document came from. The source header and the console warning now report `probe.url`, falling back to the candidate for a synthesized response that carries none. The CLI's stored-token refresh built its fetch from the global rather than from `createProxyFetch()`, so a server reachable only through `HTTPS_PROXY` was probed directly. It now builds one proxy-aware fetch and hands it to both the discovery probe and `refreshAuthorization`, so neither leg bypasses the proxy — this function runs outside `InspectorClient`, so nothing else puts a proxy under it. 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>
Review round 4 — all five addressed (039fc6f)Inline 1 & 2 — discarded response bodies are never released. Taken, both halves. A skipped probe and the failed RFC 8414 response the substitution replaces are now cancelled through a small Inline 3 — the CLI refresh path hard-codes the global fetch, bypassing Suppressed 1 & 2 —
|
Closes #2172
A plain OAuth 2.0 authorization server that publishes its RFC 8414 metadata at
/.well-known/openid-configurationcannot be connected to at all — authorization fails before the browser opens, with aZodErrornaming three fields the server had no reason to publish.The cause is upstream
discoverAuthorizationServerMetadatain@modelcontextprotocol/client@2.0.0picks its validation schema from the well-known filename that resolved, not from the document that came back:typeis"oidc"for everyopenid-configurationcandidate, so a document found there must carryjwks_uri,subject_types_supportedandid_token_signing_alg_values_supported— three fields OpenID Connect Discovery 1.0 requires and RFC 8414 does not. RFC 8414 §5 explicitly permits that filename for general OAuth metadata, so the server is conforming and the client is not. And because the parse throws rather than continuing the candidate loop (every other per-candidate failurecontinues), discovery aborts outright instead of falling through.Filed upstream as modelcontextprotocol/typescript-sdk#2733, with a standalone repro and two smaller findings in the same code path (
OpenIdProviderDiscoveryMetadataSchemais az.object, so a successful OIDC parse silently stripsrevocation_endpoint/introspection_endpoint; and the function's doc comment describes a fallback the schema selection does not do).What this PR does
core/auth/oidcDiscoveryCompat.ts— a fetch wrapper that fabricates nothing. When the RFC 8414 candidate comes back 4xx (or 502 — the statuses the SDK walks past), it fetches the OIDC candidates the SDK would try next. If one returns a document that satisfiesOAuthMetadataSchemabut fails the OIDC schema — i.e. it is RFC 8414 metadata and is not an OpenID provider document — that body is returned as the response to the RFC 8414 request, so the SDK validates it under the schema that actually describes it.Three properties worth calling out, because they are what made this the chosen approach:
jwks_uri. Back-filling the three OIDC-required fields would also make the parse succeed, but the Inspector would then be showing, in the Auth and Network tabs, a metadata document the server never published — the one thing a debugging tool must not do.issuerincluded, so the SDK's RFC 8414 §3.3 issuer-echo check runs on it unchanged.InspectorClient's base fetch — the same seamwithOAuthEndpointOverridesuses — because that is the only place that also covers the discovery the SDK runs from inside the transport, whose tracker is built inside the transport where nothing here can reach above it. That is below both fetch trackers, so a captured Network entry shows the substituted document rather than the real 404; it therefore carries anx-inspector-oauth-metadata-sourceresponse header naming the URL the body was actually fetched from, and the console warning says the same. The probe is issued through the wrapped fetch and so is not separately tracked.A document that is a valid OpenID provider document is left alone and takes the SDK's normal OIDC leg, at the cost of one duplicate request on a genuine OIDC server. That is the price of not changing behavior for the case that already works.
More generally, the probe loop advances to the next candidate only where the SDK's own loop would — a 4xx or 502. A 500, a body that will not parse, or a network error stops the shim and returns the original response, leaving the SDK to make the same request and reach the same verdict. Promoting a later candidate over an earlier one the SDK would have stopped at would turn a failure into a success, which is a worse defect than the one being worked around. It also does not gate on
content-type, because the SDK does not: doing so could skip a genuine OIDC document served with an odd media type. OnlyGETs are considered, so an authorization or token endpoint that happens to live under the RFC 8414 prefix keeps its own error.The candidate derivation (
oidcDiscoveryCandidates) is a hand-written mirror of the SDK'sbuildDiscoveryUrls— a fetch wrapper sees a request URL, not the authorization-server URL — so a test pins it against the SDK's own exportedbuildDiscoveryUrlsacross root, path, and trailing-slash AS URLs. The two cannot drift.Reproducing it
New showcase server,
test-servers/configs/oauth-rfc8414-at-oidc-path-http.json: an OAuth-protected server whose RFC 8414 metadata is served only from/.well-known/openid-configuration, with the RFC 8414 route deliberately unserved. Load it with--config, add the server, connect — authorization proceeds normally on this branch and fails with the issue'sZodErroronv2/main.That needed one knob on the test server,
oauth.asMetadataPath, mirroring the existingoauth.resourceMetadataPath(#2071) field in shape, validation, and the "move it, don't also serve the default" semantics.Tests
clients/web/src/test/core/auth/oidcDiscoveryCompat.test.ts— 28 unit tests: thebuildDiscoveryUrlsdrift guard, the substitution on both OIDC candidate shapes and its source header, the genuine-OIDC passthrough, the well-known boundary and GET-only guards, and every stop/continue path (unparseable body, 500, throwing probe, 404 walk-past, odd media type, root AS).clients/web/src/test/integration/auth/rfc8414AtOidcPath.test.ts— end to end against a real server: the unwrapped SDK rejects it (so the workaround is load-bearing, and this test starts failing the day the SDK is fixed), the wrapper resolves it, the returned metadata carries none of the three OIDC-only fields, and the fetch the transport is handed carries the shim.clients/cli/__tests__/stored-auth.test.ts— the stored-token refresh path, which calls SDK discovery directly rather than througheffectiveAuthFetchand so carries its own copy of the shim.Verification
npm run local:gatepasses. No screenshots — there is no UI change; the observable difference is that a connection which previously failed now completes.