-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(client): validate discovery and endpoint URLs in the OAuth flow #2447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
felixweinberger
wants to merge
1
commit into
main
Choose a base branch
from
fweinberger/oauth-discovery-url-validation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,435
−203
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@modelcontextprotocol/client': patch | ||
| '@modelcontextprotocol/core-internal': patch | ||
| --- | ||
|
|
||
| Validate every network-derived URL in the client OAuth flow before it is requested or adopted. A new fail-closed URL policy (`assertAllowedDiscoveryUrl`, `DiscoveryUrlBlockedError`, `DiscoveryUrlPolicyOptions`) runs ahead of all discovery I/O: `https:` required everywhere with the RFC 8252 loopback `http:` exemption, no userinfo, no loopback/private/link-local IP-literal targets unless the producing step — the MCP server, or the authorization server that published an endpoint (`ctx.producer`) — is itself local (DNS names are never resolved, so private https DNS names keep working), RFC 8414 §2 issuer syntax for authorization server identifiers, and RFC 9728 §3 same-origin for protected-resource metadata. An explicit metadata URL carries its provenance (`resourceMetadataUrlSource`): the transports label the URLs they extract from a `WWW-Authenticate` challenge `'www-authenticate'`, and a non-conformant challenge-relayed URL is ignored with a warning while discovery falls back to the SDK's own same-origin well-known derivation — whereas a caller-configured `resourceMetadataUrl` (`'caller'`, the default) is a configuration statement and fails closed with `DiscoveryUrlBlockedError` instead of silently discovering at a different location. A rejected `authorization_servers[0]` fails the flow instead of silently switching to the legacy server-derived authorization server, and cached `discoveryState` is re-validated on every restore. Discovery requests now use `redirect: 'manual'` with a bounded 3-hop follow that re-validates each `Location` target and drops headers on cross-origin hops. `startAuthorization`, `registerClient`, and the token request path (`exchangeAuthorization`, `refreshAuthorization`, `fetchToken`) apply the same https-or-loopback and locality rules to the authorization, registration, and token endpoints, keyed on the authorization server that published them (the plain-http scheme rule on the token endpoint keeps throwing `InsecureTokenEndpointError`, unchanged). The Cross-App Access token exchanges (`requestJwtAuthorizationGrant`, `discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) go through the same validated path: a token endpoint discovered from the IdP's metadata is keyed on the IdP issuer, while a caller-supplied endpoint anchors its own locality check; these standalone helpers accept a per-call `discoveryPolicy` and, running without a provider, have no provider hook. Token and registration POSTs are issued with `redirect: 'manual'` and are never re-sent to a `Location` target — token responses are terminal (RFC 6749), so a redirected POST surfaces as an error instead of re-sending credentials. In browser runtimes the Fetch API filters `redirect: 'manual'` responses into opaque redirects (status 0, no readable `Location`), so a redirect on an auth-flow request cannot be examined or followed there; the handling depends on what the request was for: the SDK's own well-known discovery probes treat a filtered redirect as an unavailable document and degrade exactly like a 404 (a browser deployment whose web tier answers unknown paths with a redirect keeps taking the legacy authorization-server fallback), a challenge-relayed metadata URL whose response is a filtered redirect is ignored with a warning while discovery falls back to the well-known derivation, a caller-configured `resourceMetadataUrl` fails the flow, and a redirected token or registration POST fails in every runtime — those requests are never re-sent to a redirect target, so only serving the endpoint without a redirect resolves it. New opt-outs, all policy-weakening and off by default, ride an optional `discoveryPolicy` bag: `OAuthClientProvider.discoveryPolicy` is the durable per-deployment home read by every flow the provider drives (including the transports' `401`/`403` and `finishAuth` flows), and a per-call `discoveryPolicy` on `AuthOptions` or the standalone discovery and endpoint functions takes precedence — the effective policy is resolved once at flow entry. Trust verification is configured on the provider only (there is no per-call trust override): the exhaustive `OAuthClientProvider.trustedAuthorizationServers` list of issuer identifiers (matched on the full identifier, tolerating only a trailing-slash difference — a path-distinguished issuer must be listed exactly, and an origin-only entry does not cover pathed issuers on that host) applies to every RFC 9728 §7.6 adoption path, including the legacy fallback where a server without protected-resource metadata acts as its own authorization server (hooks see that path as `ctx.source === 'sdk-derived'`); and the optional `OAuthClientProvider.validateDiscoveryURL(ctx)` hook runs after the mechanical policy at every checkpoint — before each discovery, token, and registration request (including each redirect hop) and at each assert-only adoption (authorization-server adoption, cached restore, and the authorization endpoint before redirect). A hook throw that is not a `DiscoveryUrlBlockedError` is wrapped into one (original error as `cause`), so callers observe a single failure type for every URL-policy or trust rejection. | ||
|
|
||
| Transport-configured `requestInit` headers no longer ride on the OAuth requests the client transports issue. `StreamableHTTPClientTransport` and `SSEClientTransport` previously merged `requestInit` into every request, including protected-resource metadata, authorization-server metadata, token, and client registration requests that can target a different origin than the MCP server; those requests now go through the bare `fetch` (a custom `fetch` option still applies everywhere). The new `oauthRequestInit` transport option is the explicit opt-in for configuring authorization requests; its headers follow the discovery path's per-hop rule and are not re-applied when a discovery redirect leaves the current origin. See the "Auth" section of `docs/migration/upgrade-to-v2.md` for the migration notes covering both the URL policy defaults and the header isolation. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The new client OAuth surface added by this PR —
OAuthClientProvider.discoveryPolicy(withallowHttpDiscovery/allowCrossOriginResourceMetadata/allowPrivateAddressTargets),trustedAuthorizationServers,validateDiscoveryURL,DiscoveryUrlBlockedError, and therequestInit→oauthRequestInitheader isolation on both client transports — is documented only in this migration-guide entry and the changeset. The client OAuth guide pagedocs/clients/oauth.md(which documents the OAuthClientProvider surface and transport-driven auth) anddocs/clients/machine-auth.md(which documents the Cross-App Access helpers now subject to the URL policy) are not updated, so users configuring OAuth via the guide pages won't discover the new fail-closed defaults, theDiscoveryUrlBlockedErrorfailure mode, or the remedies for previously-working deployments.Extended reasoning...
What's missing. This PR adds a substantial amount of new public API to the client OAuth flow: the fail-closed discovery URL policy (
OAuthClientProvider.discoveryPolicy/AuthOptions.discoveryPolicywith theallowHttpDiscovery,allowCrossOriginResourceMetadata, andallowPrivateAddressTargetsopt-outs), the RFC 9728 §7.6 trust memberstrustedAuthorizationServersandvalidateDiscoveryURL(ctx), the newDiscoveryUrlBlockedErrorfailure type, and therequestInit→oauthRequestInitheader isolation onStreamableHTTPClientTransport/SSEClientTransport. The only prose documentation for all of this lives indocs/migration/upgrade-to-v2.md(this section) and the changeset.Which pages should cover it.
docs/clients/oauth.mdis the guide page that documents theOAuthClientProvidersurface (clientInformation,tokens,discoveryState,validateResourceURL,skipIssuerMetadataValidation) and the transport-driven auth flow viaconnect()/finishAuth(), including the failure modes callers may see (UnauthorizedError,IssuerMismatchError). A grep acrossdocs/**/*.mdshows none of the new symbols —discoveryPolicy,trustedAuthorizationServers,validateDiscoveryURL,oauthRequestInit,DiscoveryUrlBlockedError— appear anywhere except the migration guide; the OAuth guide page is untouched by this PR. Similarly,docs/clients/machine-auth.mddocumentsrequestJwtAuthorizationGrant/discoverAndRequestJwtAuthGrant/exchangeJwtAuthGrant/CrossAppAccessProviderwithout mentioning that those token exchanges now go through the URL policy or that they accept a per-calldiscoveryPolicyoption.Why it matters. The new defaults are behavior changes that a user following the guide pages will hit directly: the fail-closed policy can reject previously-working deployments (e.g. cross-origin
resource_metadatabehind a gateway, or a private-IP-literal authorization server), and transportrequestInitheaders no longer reach OAuth requests at all. The remedies — settingprovider.discoveryPolicy, listingtrustedAuthorizationServers, or moving gateway headers tooauthRequestInit— are currently only discoverable from the migration guide, which is not the page a user configuring anOAuthClientProviderfrom scratch reads. The repo's own review checklist asks that new features carry prose documentation (not just JSDoc / migration notes) and that behavior changes be reflected where existing docs describe the old behavior.Concrete example. A user reading
docs/clients/oauth.mdtoday implements a provider and connects withStreamableHTTPClientTransport+requestInit: { headers: { 'X-Api-Key': ... } }against a server whoseWWW-Authenticatechallenge points at a cross-origin metadata URL. After this PR: (1) the gateway header silently stops being sent on the well-known / token / registration requests (they needoauthRequestInitnow), and (2) if the discovery flow adopts a URL that violates the policy,connect()rejects withDiscoveryUrlBlockedError— a failure type the guide never mentions. Nothing on the page they read explains either behavior or its fix.How to fix. Add a section to
docs/clients/oauth.mdcovering the new provider members (discoveryPolicy,trustedAuthorizationServers,validateDiscoveryURL), the default fail-closed rules and their opt-outs,DiscoveryUrlBlockedErroras a failure callers ofconnect()/finishAuth()may see, and therequestInitvsoauthRequestInitsplit on the transports. Add a short note todocs/clients/machine-auth.mdthat the Cross-App Access token exchanges enforce the same URL policy and accept adiscoveryPolicyoption. This is a docs-completeness gap only — nothing breaks at runtime if merged as-is, and the migration guide/changeset do document the changes — so it is a nit, not a blocker.