🔐 fix: Prefer WWW-Authenticate resource_metadata Hint for MCP OAuth (#12763)

* 📦 chore: Bump @modelcontextprotocol/sdk to v1.29.0

* ♻️ refactor: Extract WWW-Authenticate Probe Helper for MCP OAuth

* 🔐 fix: Prefer WWW-Authenticate resource_metadata Hint for MCP OAuth

Per RFC 9728 §5.1, the `resource_metadata=<url>` parameter in a 401
`WWW-Authenticate: Bearer` challenge is the authoritative protected-resource
metadata source. Path-aware `.well-known` discovery was winning over the
hint, so split deployments that serve valid-but-wrong metadata at the
path-aware endpoint stranded OAuth at defunct authorization servers.

Threads the hint through `discoverOAuthProtectedResourceMetadata` via
`opts.resourceMetadataUrl` in both startup detection and the OAuth handler,
matching the behavior of Claude Desktop, the MCP Inspector, OpenAI tooling,
and Microsoft Copilot Studio.

Fixes #12761.

* 🧵 fix: Thread OAuth-Aware fetchFn Through Resource-Metadata Probe

Without this, admin-configured `oauthHeaders` (e.g. a gateway API key that
fronts the MCP endpoint) were stripped from the probe, causing the gateway
to 401 for the wrong reason and masking the real `WWW-Authenticate` hint.

The helper now accepts a FetchLike and defaults to global fetch, so the
startup detection path is unchanged while the handler passes its OAuth-
aware wrapper through.

* 🧹 refactor: Address MCP OAuth Probe Review Findings

- Thread `fetchFn` through `probeResourceMetadataHint` so admin-configured
  `oauthHeaders` reach the probe (a gateway API key that fronts the MCP
  endpoint would otherwise 401 us for the wrong reason and hide the real
  Bearer challenge).
- Skip the redundant HEAD request in `checkAuthErrorFallback` when the
  probe already observed a 401/403; fall back to a fresh HEAD only when
  every probe attempt threw (transient network error).
- Narrow the oauth barrel: drop `export * from './resourceHint'` so the
  helper stays an internal module.
- Add `scope` extraction coverage (`Bearer scope="read write"`) and a
  403-only observation path; isolate `MCP_OAUTH_ON_AUTH_ERROR=true` in a
  dedicated suite so precise-outcome tests aren't muddied by the safety net.

*  fix: Use Zod Schema in MCP Reconnection-Storm Test Tool

MCP SDK 1.28 tightened `McpServer.tool()` to require Zod schemas instead
of plain JSON-Schema objects. Swap the `{ message: { type: 'string' } }`
shape for `z.string()` so the fixture server spins up under SDK 1.29.

* 🛡️ fix: Harden MCP OAuth resource_metadata Hint Against SSRF

The `resource_metadata` URL is echoed from an untrusted MCP server, so
handing it straight to the SDK lets a malicious server redirect discovery
at private IPs, the cloud metadata service, or any host the admin did not
intend to reach. Caught by the Copilot review on #12763.

- `handler.ts`: run the hint through the same `validateOAuthUrl` /
  `allowedDomains` gate that already guards the authorization-server URL;
  drop it and fall back to path-aware discovery on rejection.
- `detectOAuth.ts`: no admin-scoped allowedDomains here, so apply a
  strict `isSSRFTarget` + DNS resolution check and silently discard any
  hint pointing at a private/loopback/metadata address.
- Tests cover both the hostname-list and DNS-resolution rejection paths
  and assert the SDK falls back to path-aware discovery unharmed.

* 🧪 test: Mock ~/auth in fallback Suite for Consistency

Matches the main `detectOAuth.test.ts` mock so the SSRF guards added in the
previous commit don't touch the real `~/auth` module at test time.

* 🔍 fix: Scope OAuth Fallback to HEAD + Parse Multi-Scheme WWW-Authenticate

Two codex findings on #12763:

- **P1**: the merged `authChallenge` flag was letting POST-only 401/403 flip
  the `MCP_OAUTH_ON_AUTH_ERROR` fallback, misclassifying WAF/CSRF-hardened
  endpoints (HEAD 200 + POST 403) as OAuth-required. Rename to
  `headAuthChallenge` and derive it only from the HEAD probe, matching the
  legacy fallback's HEAD-only semantics. Add a regression test.
- **P2**: the SDK's `extractWWWAuthenticateParams` only inspects the first
  scheme token, so multi-scheme headers like
  `Basic realm="api", Bearer resource_metadata="..."` silently dropped the
  authoritative Bearer hint. Fall back to a regex across the full header
  when the SDK returns nothing but Bearer is present. Add a regression
  test covering the multi-scheme case.

* 🧽 refactor: Tighten MCP OAuth Probe Semantics

Addresses the second external review pass plus codex P2:

- Merge the two stacked JSDoc blocks on `probeResourceMetadataHint` into one
  with a proper `@returns` section.
- Only short-circuit HEAD when it delivered the `resource_metadata` hint
  itself — a Bearer-without-params HEAD now lets POST run, since some
  servers surface their hint only on POST and we were missing it.
- Drop the unused `scope` field from `ResourceHintProbeResult`; no caller
  read it, and YAGNI beats a reserved field.
- Remove the redundant `OAUTH_ON_AUTH_ERROR` guard inside
  `checkAuthErrorFallback` — the only call site already gates on it.
- Codex P2: signal "HEAD status unknown" via `null` when the HEAD probe
  threw and POST returned non-auth. Previously that combination leaked a
  `{headAuthChallenge: false}` result and silently skipped the fallback's
  retry HEAD, which could misclassify OAuth-required servers after a
  transient HEAD failure.

Regression tests cover every path: Bearer-no-hint-on-HEAD + hint-on-POST,
multi-scheme `Basic + Bearer` headers, HEAD-threw + POST-200 retry, and
the WAF/CSRF-only POST 403 case.

* 🪥 polish: Tighten Probe Null-Guard Ordering + Add Malformed-Hint Test

Two NITs from the follow-up review:

- Move `bearerChallenge` computation after the `!wwwAuth` guard so the
  variable is only derived when it can be meaningfully `true`. The
  early-return path is now a clean unconditional exit.
- Add a regression test that asserts `Bearer resource_metadata="not-a-url"`
  yields `resourceMetadataUrl: undefined` without throwing, locking in
  the try/catch safety net in `extractHintFromHeader` and the SDK
  parser alike.
This commit is contained in:
Danny Avila 2026-04-21 16:26:20 -07:00 committed by GitHub
parent 2427edef90
commit f5c4deac28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1084 additions and 164 deletions

View file

@ -48,7 +48,7 @@
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@modelcontextprotocol/sdk": "^1.27.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@node-saml/passport-saml": "^5.1.0",
"@smithy/node-http-handler": "^4.4.5",
"ai-tokenizer": "^1.0.6",