Skip to content

fix(client): validate discovered AS metadata by document shape, not well-known path - #2734

Open
claude[bot] wants to merge 7 commits into
mainfrom
fix/2733-oauth-discovery-schema-by-document
Open

fix(client): validate discovered AS metadata by document shape, not well-known path#2734
claude[bot] wants to merge 7 commits into
mainfrom
fix/2733-oauth-discovery-schema-by-document

Conversation

@claude

@claude claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Requested by Felix Weinberger · Slack thread

Fixes #2733

Problem

discoverAuthorizationServerMetadata() picks its validation schema from which well-known filename resolved, not from the document that came back: every openid-configuration candidate is parsed with OpenIdProviderDiscoveryMetadataSchema, which requires jwks_uri, subject_types_supported and id_token_signing_alg_values_supported — fields OpenID Connect Discovery 1.0 requires and RFC 8414 does not. A plain OAuth 2.0 authorization server publishing conforming RFC 8414 metadata at /.well-known/openid-configuration (explicitly permitted by RFC 8414 §5) is therefore rejected — and because the .parse() throws instead of continuing the candidate loop (unlike the existing 4xx/502/CORS continues), the whole connection fails. Reproduced against @modelcontextprotocol/client@2.0.0 with the issue's injected-fetchFn script before writing the fix (ZodError on exactly those three fields).

Changes

  • packages/client/src/client/auth.ts — validate by document shape: the schema implied by the well-known path is tried first (safeParse), then the other one; a document that fits neither is treated like the other per-candidate failures and the next candidate URL is tried instead of aborting discovery. Issuer validation (RFC 8414 §3.3 / OIDC Discovery §4.3) is unchanged and still runs on fallback-parsed documents. The doc comment now describes the actual behavior (it previously claimed a schema fallback that only the URL ordering had).
  • packages/core/src/auth.tsOpenIdProviderDiscoveryMetadataSchema is now z.looseObject(...) like the component schemas it is built from, so a successful OIDC parse no longer strips RFC 8414 fields absent from the OIDC shape (revocation_endpoint, introspection_endpoint, …) — the issue's second finding.
  • Changeset: patch for @modelcontextprotocol/core and @modelcontextprotocol/client.

Tests

Four regression tests in packages/client/test/client/auth.test.ts, all verified red without the source change (4 failed / 804 passed) and green with it:

  • RFC 8414 metadata served only at the openid-configuration path is returned intact (the issue's repro shape);
  • RFC 8414 fields on a mixed OIDC document survive the parse (loose-object pin);
  • a document fitting neither schema skips to the next candidate URL (3 fetches, discovery still succeeds);
  • issuer validation still rejects a mismatched RFC 8414 document found at the OIDC path (IssuerMismatchError).

Verification

  • @modelcontextprotocol/client: 808/808 tests; @modelcontextprotocol/core: 2/2; @modelcontextprotocol/core-internal: 1447/1447
  • pnpm typecheck:all clean; ESLint + Prettier clean on the touched packages

Generated by Claude Code

…ell-known path

discoverAuthorizationServerMetadata() picked its validation schema from
which well-known filename resolved, so conforming RFC 8414 metadata served
at /.well-known/openid-configuration (permitted by RFC 8414 §5) was parsed
against the OIDC Discovery schema and rejected for lacking jwks_uri,
subject_types_supported and id_token_signing_alg_values_supported — and the
parse threw out of the candidate loop, aborting discovery entirely.

- Try the schema implied by the path first, then the other one; a document
  that fits neither skips to the next candidate URL like the existing 4xx /
  502 / CORS failures.
- Make OpenIdProviderDiscoveryMetadataSchema a loose object like its
  component schemas so mixed OIDC/OAuth documents keep their RFC 8414
  fields (revocation_endpoint, introspection_endpoint).
- Correct the discovery doc comment; issuer validation is unchanged.

Fixes #2733

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ
@claude
claude Bot requested a review from a team as a code owner August 28, 2026 05:37
@changeset-bot

changeset-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2112499

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/codemod Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2734

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2734

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2734

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2734

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2734

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2734

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2734

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2734

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2734

commit: 2112499

Comment thread packages/client/src/client/auth.ts
Comment thread packages/core/src/auth.ts
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/client/src/client/auth.ts Outdated
…ry diagnosable

- Declare the RFC 8414 revocation/introspection fields on
  OpenIdProviderDiscoveryMetadataSchema with their OAuth validators so a
  successful OIDC parse validates them instead of passing them through the
  loose object's catchall.
- On a fallback parse, drop the top-level fields the path-implied schema
  rejected so values that failed their declared validators (e.g. an unsafe
  jwks_uri) cannot ride through the fallback schema's passthrough.
- Guard response.json() so a 200 with a non-JSON body skips to the next
  candidate like the other per-candidate failures.
- When every candidate fails schema validation, throw an error naming the
  URL and the schema issues instead of returning undefined and letting
  auth() silently guess default endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest push brings back 2 issues flagged in earlier reviews; replied in those comments' threads.

…rospection_endpoint

- Run the sibling schema's parse on BOTH branches and drop the top-level
  fields it rejected, so a mixed document served at the
  oauth-authorization-server path gets the same sanitization as at the
  openid-configuration path (an unsafe jwks_uri / op_policy_uri /
  mis-typed subject_types_supported can no longer ride through the OAuth
  schema's passthrough by choice of well-known path). This also covers
  service_documentation, which the OIDC shape declares as a plain string
  while the OAuth schema requires a safe URL.
- Validate introspection_endpoint with SafeUrlSchema in OAuthMetadataSchema
  (matching revocation_endpoint), so the field newly declared on the
  discovery schema rejects javascript:/data:/vbscript: values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ
Comment thread packages/core/src/auth.ts
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/client/src/client/auth.ts Outdated
…ip validated ones

- Invalid values in optional metadata fields now drop the field instead of
  failing the document: the parse retries once with the rejected top-level
  fields removed, so a relative revocation_endpoint or unsafe
  introspection_endpoint no longer aborts an auth flow that never uses
  them; invalid required fields still fail the parse.
- The sibling-schema sanitization only removes fields the accepting schema
  does NOT declare (looseObject passthrough keys): a field the accepting
  schema itself declared and validated (e.g. OIDC's string
  service_documentation) is never removed.
- The response.json() guard now swallows only SyntaxError (a non-JSON
  body); network errors reading the body and aborts propagate again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request has now been reviewed several times and this review found new issues. Before patching these one by one, step back: would one root-cause fix close several of them? Is the pull request's scope growing with each push? Prefer root-cause fixes, keep scope fixed, and note out-of-scope improvements as follow-ups.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 packages/client/src/client/auth.ts — nit: The JSDoc still promises "undefined if discovery fails" and lists only IssuerMismatchError under @ throws, but the function now throws a plain Error when every candidate fails schema validation (line 1950), so callers coded against the documented undefined-on-failure contract (e.g. discoverAndRequestJwtAuthGrant's if (!metadata?.token_endpoint) fallback in crossAppAccess.ts:211) get an unexpected exception instead of their fallback path. Fix: update @ returns/@ throws to document the new schema-mismatch throw so the contract matches the implementation, per REVIEW.md's doc-vs-implementation check.

    Extended reasoning...

    The diff adds if (schemaFailure) throw new Error(...) at packages/client/src/client/auth.ts:1949-1953: when any candidate returned valid JSON that fits neither metadata schema and no candidate succeeds, discovery now throws instead of returning undefined. The JSDoc directly above (updated by this same diff for the fallback-strategy bullets) still says at line 1826 "@ returns Promise resolving to authorization server metadata, or undefined if discovery fails" and at line 1827 "@ throws {IssuerMismatchError}" only. This is exported public API (packages/client/src/index.ts:25). A consumer following the documented contract — e.g. the SDK's own discoverAndRequestJwtAuthGrant (packages/client/src/client/crossAppAccess.ts:209-213), which handles failure via if (!metadata?.token_endpoint) throw new Error('Failed to discover token endpoint for IdP: ...') — instead gets the raw schema-mismatch Error for a near-miss document; any downstream code that catches/branches on the documented error surface misses it. On base the JSDoc matched more closely (only ZodError/HTTP throws leaked). REVIEW.md's Documentation…

    Verification: nit — triggered whenever every discovery candidate fails and at least one returned valid JSON fitting neither schema; the doc/code mismatch itself is unconditional in the checkout. Verified: packages/client/src/client/auth.ts:1826 still reads "@ returns Promise resolving to authorization server metadata, or undefined if discovery fails" and line 1827 lists only "@ throws…

Comment thread packages/core/src/auth.ts
Comment thread packages/client/src/client/auth.ts Outdated
Comment thread packages/client/src/client/auth.ts Outdated
Comment on lines +1974 to +1993
function parseMetadataDroppingInvalidOptionalFields(schema: AuthorizationServerMetadataSchema, json: unknown) {
const first = schema.safeParse(json);
if (first.success || typeof json !== 'object' || json === null) {
return first;
}
const failedPresentKeys = new Set<string>();
for (const issue of first.error.issues) {
const key = issue.path[0];
if (typeof key === 'string' && key in json) {
failedPresentKeys.add(key);
}
}
if (failedPresentKeys.size === 0) {
return first;
}
const stripped: Record<string, unknown> = { ...(json as Record<string, unknown>) };
for (const key of failedPresentKeys) {
delete stripped[key];
}
const second = schema.safeParse(stripped);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit: parseMetadataDroppingInvalidOptionalFields drops ANY invalid optional field, including ones the SDK actively consumes — a malformed registration_endpoint (e.g. relative "/register") is silently removed, so dynamic registration later fails with the misleading "Incompatible auth server: does not support dynamic client registration" instead of the base's ZodError naming the bad field; a mistyped scopes_supported or token_endpoint_auth_methods_supported likewise silently alters scope/auth-method selection. Fix: only drop fields the client never dereferences (allowlist), or attach the dropped-field issues to downstream errors so consumed fields still fail diagnosably.

Extended reasoning...

The inline comment at auth.ts:1899-1902 justifies the drop as safe for "an auth flow that never uses those fields", but the loop at 1979-1993 collects every top-level key with a Zod issue and strips it, with no restriction to unused fields. Trigger: an AS publishes otherwise-valid RFC 8414 metadata with registration_endpoint: "/register" (relative — SafeUrlSchema rejects it; both schemas declare the field, so neither accepts it as passthrough). After merge: first safeParse fails on registration_endpoint, the retry at 1989-1992 succeeds with the field deleted, discovery returns metadata without registration_endpoint. Later, registerClient (auth.ts:2511-2512) sees metadata present but no registration_endpoint and throws "Incompatible auth server: does not support dynamic client registration" — a wrong diagnosis (the server DOES support DCR, its URL was just relative). On base, OAuthMetadataSchema.parse threw a ZodError naming registration_endpoint, pointing the operator at the actual defect. Same mechanism for other SDK-consumed optional fields: scopes_supported or…

Verification: nit — triggered when an authorization server (external, unconstrained input) publishes otherwise-valid metadata with an invalid value in an optional field the SDK actually consumes, e.g. registration_endpoint: "/register" (relative). Mechanism verified in /home/claude/typescript-sdk/packages/client/src/client/auth.ts:1974-1995: parseMetadataDroppingInvalidOptionalFields collects EVERY…

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standing pat on this one, deliberately, so the field policy stays uniform (95d81ce states it once: URL fields scheme-guarded everywhere; invalid optional fields drop; required fields reject). Two reasons: (1) silent-drop of invalid optional fields is exactly what the OIDC path's z.object strip did on base for every one of these fields, so this is not a new failure mode — and on the OAuth path, base hard-failed the entire auth flow on a relative registration_endpoint, which is strictly worse than a later, admittedly less precise, DCR error; (2) an allowlist of "fields the SDK dereferences" goes stale the day a new consumer lands. Carrying the dropped-field issues into downstream errors is a real improvement but is diagnostics plumbing beyond this fix's scope — noting it as a follow-up candidate (attach dropped-field summaries to the discovery result or an optional warning hook) for the maintainers to weigh.


Generated by Claude Code

…s, JSDoc

Unified policy, stated once: every URL-carrying optional field is validated
with SafeUrlSchema on both discovery schemas (service_documentation joins
the pick, closing the javascript:-scheme hole the OIDC z.string() left);
invalid optional fields are dropped, mirroring the strip semantics the OIDC
path always had; invalid or missing required fields reject the document.

- explicit MetadataParseResult return type on
  parseMetadataDroppingInvalidOptionalFields (repo convention)
- discoverAuthorizationServerMetadata JSDoc: @returns/@throws now state the
  undefined-vs-throw contract (crossAppAccess's discoverAndRequestJwtAuthGrant
  checked: it throws its own error on undefined and has no catch-and-continue,
  so the new, more specific throw propagates the same way)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Round-4 findings addressed in 95d81ce with one policy stated once, per the review's step-back note: every URL-carrying optional field is validated with SafeUrlSchema on both discovery schemas; an invalid optional field is dropped (the strip semantics the OIDC path always had); an invalid or missing required field rejects the document; a document fitting neither schema skips to the next candidate, and exhausting all candidates with only schema failures throws a diagnosable error.

On the two body-level notes: the discoverAuthorizationServerMetadata JSDoc now documents the undefined-vs-throw contract (@returns/@throws), and crossAppAccess.ts's discoverAndRequestJwtAuthGrant was checked — it throws its own error on undefined and has no catch-and-continue, so the new, more specific throw propagates to its callers the same way the old generic error did.

One flagged item is deliberately not changed (rationale in its thread): invalid SDK-consumed optional fields (e.g. a relative registration_endpoint) drop like any other rather than being special-cased — uniform with base's OIDC-path strip behavior, and strictly better than base's OAuth-path hard failure. Surfacing dropped-field diagnostics downstream is noted as a follow-up for maintainers. This is the final review-driven push on this PR; further reshaped findings will be summarized rather than patched.


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request has now been reviewed several times and this review found new issues. Before patching these one by one, step back: would one root-cause fix close several of them? Is the pull request's scope growing with each push? Prefer root-cause fixes, keep scope fixed, and note out-of-scope improvements as follow-ups.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 packages/client/src/client/auth.ts — The introspection_endpoint tightening (z.string() → SafeUrlSchema in packages/core/src/auth.ts:65) is only softened inside discoverAuthorizationServerMetadata's drop-invalid-optional-fields retry; the deprecated but still-exported discoverOAuthMetadata here still calls OAuthMetadataSchema.parse raw, so a server whose otherwise-valid metadata carries a relative or empty introspection_endpoint now throws a ZodError and aborts the caller's auth flow where base returned the metadata. Fix: give every parse site reached by the tightened schema the same invalid-optional-field tolerance (safeParse + drop), not just the new discovery path.

    Extended reasoning...

    Trigger: an authorization server publishes RFC 8414 metadata with introspection_endpoint: "/oauth/introspect" (relative) or an empty string — a field the client SDK never dereferences. Path: a consumer still on the deprecated public API calls discoverOAuthMetadata (packages/client/src/client/auth.ts:1698); line 1735 runs OAuthMetadataSchema.parse(await response.json()) with no safeParse/drop retry. Because this diff tightens introspection_endpoint from z.string().optional() to SafeUrlSchema.optional() (packages/core/src/auth.ts:65), the parse now throws a ZodError, propagating out and aborting the auth flow. On base the same document parsed successfully (any string accepted). The PR's mitigation — parseMetadataDroppingInvalidOptionalFields (auth.ts:1981) — is wired only into discoverAuthorizationServerMetadata (lines 1906/1913), so this sibling site is left failing exactly the way the resolved core/auth.ts:65 finding described, but on a path that fix did not cover. Same exposure applies to any external consumer of the published OAuthMetadataSchema from @…

    Verification: normal — triggered when an authorization server (external, unconstrained input) publishes otherwise-valid RFC 8414 metadata whose optional introspection_endpoint is a non-absolute-URL string (e.g. relative "/oauth/introspect" or ""), and a consumer uses the still-exported deprecated API. Mechanism verified: the diff tightens packages/core/src/auth.ts:65 from `introspection_endpoint:…

The introspection_endpoint tightening made the deprecated (still exported)
discoverOAuthMetadata throw on documents base accepted — its raw
OAuthMetadataSchema.parse was the one call site left outside the
drop-invalid-optional-fields policy (repo-wide grep: no others). It now
reuses parseMetadataDroppingInvalidOptionalFields, throwing the original
ZodError only when required fields fail, with a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Round-5 finding (review body, no inline anchor) fixed in a55e373: the deprecated but still-exported discoverOAuthMetadata was the one remaining raw OAuthMetadataSchema.parse call site (verified by repo-wide grep — no other exported path raw-parses the tightened schemas), so the introspection_endpoint tightening would have made it throw on documents base accepted. It now reuses parseMetadataDroppingInvalidOptionalFields — invalid optional fields drop, required-field failures still throw the original ZodError — with a regression test on the deprecated path (relative introspection_endpoint → document returned without the field). Client tests 816/816, typecheck and lint clean.

Per the earlier note, this closes the review-driven pushes on this PR: any further findings will be summarized in a comment rather than patched, and the maintainer review has the final word.


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request has now been reviewed several times and this review found new issues. Before patching these one by one, step back: would one root-cause fix close several of them? Is the pull request's scope growing with each push? Prefer root-cause fixes, keep scope fixed, and note out-of-scope improvements as follow-ups.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 packages/core/src/auth.ts — This PR's scheme-guard sweep (introspection_endpoint → SafeUrlSchema; discovery-schema comment "every URL-carrying field is scheme-guarded uniformly") skips the sibling URL fields in this same module: OAuthProtectedResourceMetadataSchema's jwks_uri, resource_policy_uri and resource_tos_uri stay z.string().url(), which accepts javascript:/data:/vbscript: URLs, so discoverOAuthProtectedResourceMetadata still returns e.g. resource_policy_uri: "javascript:alert(1)" as validated metadata — the exact hole this PR closes for AS metadata. Fix: validate every URL-typed field in this module with SafeUrlSchema (jwks_uri, resource_policy_uri, resource_tos_uri), matching authorization_servers on line 32.

    Extended reasoning...

    Path: discoverOAuthProtectedResourceMetadata (packages/client/src/client/auth.ts) parses the RFC 9728 document with OAuthProtectedResourceMetadataSchema (packages/core/src/auth.ts:30-45). A malicious or compromised MCP resource server serves a PRM document with resource_policy_uri: "javascript:alert(1)" (or the same in resource_tos_uri or jwks_uri). z.string().url() (lines 33, 39, 40) validates any WHATWG-parseable URL including javascript:/data:/vbscript: schemes, so the parse succeeds and auth()/discoverOAuthServerInfo hand the value to providers and host apps as validated resourceMetadata — consent/authorization UIs commonly render the policy and ToS links from PRM, giving a script-scheme navigation target that the SDK's own SafeUrlSchema exists to block (see authorization_servers on line 32 and the fields this PR tightened at line 65 and 133-145). Base behaves identically for the PRM schema, but this PR is precisely the sweep that migrates URL metadata fields in this module from plain string/url validators to SafeUrlSchema (changeset: introspection_endpoint "validated as a safe…

    Verification: normal — security-relevant and reachable once merged (a malicious or compromised MCP resource server serving a crafted RFC 9728 protected-resource metadata document is the trigger; that document is untrusted external input). Mechanism verified: packages/core/src/auth.ts:33,39,40 leave jwks_uri: z.string().url().optional(), resource_policy_uri: z.string().url().optional(), `resource_tos_uri:…

Comment thread packages/client/src/client/auth.ts Outdated
Comment thread .changeset/oauth-discovery-schema-by-document.md
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Round-6 findings noted but not patched — they expand scope beyond this fix, and the review's own step-back guidance says to note such items as follow-ups:

  • OAuthProtectedResourceMetadataSchema URL fields (jwks_uri, resource_policy_uri, resource_tos_uri) accept unsafe schemes via z.string().url() — real, but pre-existing on base and untouched by this PR, which is scoped to authorization-server discovery ([v2] OAuth discovery validates any /.well-known/openid-configuration document as OIDC, rejecting conforming RFC 8414 metadata #2733). A follow-up sweep giving the protected-resource schema the same SafeUrlSchema treatment is worth its own small PR; happy to open it if maintainers want.
  • Deprecated discoverOAuthMetadata lacks the sibling-schema sanitization — its passthrough of OIDC-declared fields is byte-for-byte base behavior (the loose OAuth schema always passed them through); this PR only removed the new hard-failure it would otherwise have introduced there. Callers wanting sanitized discovery have the non-deprecated discoverAuthorizationServerMetadata.
  • Migration-guide bullet for the behavior deltas — the patch changeset records them; whether the upgrade guide's "OAuth client flow — behavioral changes" section should carry a bullet too is a docs call I'll leave to the maintainer review (easy to add on request).

Final state: head a55e373, CI fully green, client 816/816 + core + core-internal tests, typecheck and lint clean. Ready for human review.


Generated by Claude Code

…deltas in the migration guide

- discoverOAuthMetadata now applies the same sibling-schema sanitization as
  discoverAuthorizationServerMetadata, so OIDC-declared fields it only
  passes through (jwks_uri et al.) cannot reach callers unvalidated —
  making the deprecated path consistent with the PR's stated policy.
- docs/migration/upgrade-to-v2.md 'OAuth client flow — behavioral changes'
  gains a bullet for the discovery deltas (validate-by-shape, drop invalid
  optional fields, URL guards on introspection_endpoint /
  service_documentation, throw on all-candidates schema failure), per the
  repo's breaking-changes documentation rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9hQUruXNrCqCqmMjfMCBZ
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Addendum to the round-6 summary above, on reflection (2112499): two of the three were cheap in-scope completions of this PR's own stated policy, so they are now done rather than declined — the deprecated discoverOAuthMetadata applies the same sibling-schema sanitization as the main path (with a regression test: unsafe jwks_uri dropped there too), and docs/migration/upgrade-to-v2.md's "OAuth client flow — behavioral changes" section records the discovery deltas per the repo's breaking-changes rule. The OAuthProtectedResourceMetadataSchema URL-field sweep (RFC 9728 PRM, a different document type, base behavior) remains a follow-up candidate for maintainers.

Final state: head 2112499, client 817/817, typecheck and lint clean. This is the last change on this PR ahead of human review.


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the latest push and found no new bugs — the points raised in earlier review rounds now all appear addressed. Since this rewrites OAuth discovery validation, a security-sensitive path, a human look is still worthwhile before merge.

What was reviewed this round:

  • The sibling-schema sanitization added to the deprecated discoverOAuthMetadata path, including its new unsafe-jwks_uri test — consistent with the main discovery path.
  • The new migration-guide bullet in docs/migration/upgrade-to-v2.md against the implementation — each claim (drop-invalid-optional-fields, URL guards, throw-on-all-candidates-failing, undefined reserved for no-metadata) matches the code.
  • Edge cases in dropPassthroughFieldsRejectedBySibling — sibling issues for missing required keys delete nonexistent keys (no-op), and accepting-schema-declared fields are exempt, so validated fields cannot be stripped.
Extended reasoning...

Overview

The PR (7 commits) changes authorization server metadata discovery in packages/client/src/client/auth.ts and the schemas in packages/core/src/auth.ts: discovery now validates fetched documents by shape (primary schema implied by the well-known path, falling back to the sibling schema, per RFC 8414 §5), drops invalid optional fields instead of rejecting the document, sanitizes looseObject passthrough fields the sibling schema declares and rejects, skips non-JSON 200 bodies, and throws a diagnostic error when every JSON candidate fails both schemas. OpenIdProviderDiscoveryMetadataSchema became a looseObject that explicitly declares the RFC 8414 revocation/introspection fields plus service_documentation, and introspection_endpoint gained SafeUrlSchema. Tests (+270 lines), a changeset, and a migration-guide bullet accompany the code.

Security risks

This is squarely security-sensitive: the schemas gate which URLs (token, authorization, revocation, jwks) a client will trust from a remote authorization server, and earlier rounds of this review caught real gaps (unsafe javascript: URLs riding through looseObject passthrough on various paths). The final state closes every gap I identified — both well-known paths and the deprecated discoverOAuthMetadata path now run the same sanitize-and-drop policy, and I re-verified the helper logic: fields the accepting schema declared and validated are never removed, and sibling issues for absent keys are no-ops. This run's multi-agent hunt exited on a dry streak with only duplicate candidates, refuted as repeats of already-reported items.

Level of scrutiny

High. The change alters OAuth client trust decisions and tightens a published Zod schema (introspection_endpoint) that external consumers of @ modelcontextprotocol/core may parse with directly. Six review rounds of iteration also mean the final design (uniform field policy, sibling sanitization, error-vs-undefined contract) deserves a human sign-off on the policy itself, not just its implementation — e.g., whether silently dropping invalid optional fields is the desired posture versus hard-failing. That design question is a judgment call the guidelines reserve for humans, so approval is not appropriate even with zero open findings.

Other factors

The latest commit resolved both findings from my previous review (deprecated-path sanitization; migration-guide documentation), and I confirmed the new migration prose matches the shipped behavior line by line, satisfying the repo's Documentation & Changesets recurring-catch rule. Test coverage is substantial and targets the exact adversarial cases raised earlier (unsafe endpoint values, mixed documents on either path, all-candidates-fail diagnostics, non-JSON bodies). Given the converged state, a short defer acknowledging that the back-and-forth is resolved adds real information for the human who ultimately merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2] OAuth discovery validates any /.well-known/openid-configuration document as OIDC, rejecting conforming RFC 8414 metadata

1 participant