diff --git a/.changeset/discovery-url-policy.md b/.changeset/discovery-url-policy.md new file mode 100644 index 0000000000..0a883d54b2 --- /dev/null +++ b/.changeset/discovery-url-policy.md @@ -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; a filtered redirect on a request whose outcome is required surfaces as `RedirectFilteredResponseError` (exported from `@modelcontextprotocol/client`, carrying the request's `url` and `purpose`). 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. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 89188aa893..f19956e707 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1130,6 +1130,7 @@ path will not catch them): | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | | Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | +| Runtime-filtered redirect where the response is required: caller-configured metadata URL, token / registration POST | `RedirectFilteredResponseError` (`url`, `purpose`, `redirectHop`) | | RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | | Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | | `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | @@ -1226,6 +1227,112 @@ every path including refresh — switch any plain-`http:` AS on a non-loopback h TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your `saveTokens()` implementation's responsibility. +#### Discovery URL policy (RFC 9728 §3 / §7.6, RFC 8414 §2) + +Every URL the client OAuth flow derives from the network — the `WWW-Authenticate` +`resource_metadata` URL, the protected-resource metadata's `authorization_servers[0]` +(including restore from cached `discoveryState`), each well-known discovery URL, the +authorization, registration, and token endpoints, and every redirect `Location` on a +discovery request — is now validated by `assertAllowedDiscoveryUrl` **before** any +request is made or the URL is adopted. The default policy: `https:` everywhere (`http:` only for +loopback hosts, matching the SEP-2207 token-endpoint rule), no userinfo, no +loopback/private/link-local IP-literal targets unless the step that produced the URL +(`ctx.producer` — the MCP server for URLs adopted or derived during discovery, or the +authorization server that published an endpoint) is itself local, +RFC 8414 §2 issuer syntax (no query/fragment) for authorization server identifiers, and +same-origin-with-the-server for protected-resource metadata. An explicit metadata URL +carries its provenance (`resourceMetadataUrlSource`): the transports label URLs +extracted from a `WWW-Authenticate` challenge `'www-authenticate'`, and a +non-conformant challenge-relayed URL is ignored with a `console.warn` while discovery +falls back to the SDK's own same-origin well-known derivation; a caller-configured +`resourceMetadataUrl` (`'caller'`, the default) fails closed instead — like every +other violation, with `DiscoveryUrlBlockedError` (a rejected +`authorization_servers[0]` does **not** fall back to the legacy server-derived +authorization server). Discovery +requests use `redirect: 'manual'` with a bounded (3-hop) follow that re-validates each +hop and never carries headers across origins; token and registration POSTs also use +`redirect: 'manual'` but are never re-sent to the `Location` target — token responses +are terminal (RFC 6749), so a redirected POST fails with the redirect response's +status. 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 the request: 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 +serve the token and registration endpoints without redirects. A filtered redirect on +a request whose outcome is required surfaces as `RedirectFilteredResponseError` +(carrying the request's `url` and `purpose`). The Cross-App Access token exchanges (`requestJwtAuthorizationGrant`, +`discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) follow the same rules: 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. Cross-origin authorization servers +(Auth0/Okta/Google-style) and private https DNS names are unaffected — the policy never +resolves DNS. Escape hatches (all **policy-weakening**): `discoveryPolicy` +(`allowHttpDiscovery`, `allowCrossOriginResourceMetadata`, +`allowPrivateAddressTargets`). Set it once on the provider +(`OAuthClientProvider.discoveryPolicy`) — the durable home read by every flow the +provider drives, including the transports' `401`/`403` challenge handling and +`finishAuth` — or per call on `AuthOptions` and the standalone discovery, token, +registration, and Cross-App Access functions; a per-call policy takes precedence, and +the effective policy is resolved once at flow entry (the standalone Cross-App Access +helpers run without a provider, so the per-call bag is their only policy input and the +provider trust hook does not apply to them). +For RFC 9728 §7.6 trust verification of the authorization server itself, implement the +new optional `OAuthClientProvider.validateDiscoveryURL(ctx)` hook or set +`OAuthClientProvider.trustedAuthorizationServers` (exhaustive allowlist of issuer +identifiers, matched on the full identifier with only a trailing-slash tolerance — a +path-distinguished issuer such as `https://as.example.com/tenant1` must be listed +exactly, and an origin-only entry does not cover pathed issuers on that host). Trust +is configured on the provider only — there is no per-call trust override; a caller +that needs trust verification constructs a minimal provider carrying these members +and drives the flow through `auth()` or a transport (the standalone +discovery functions apply the URL policy but run no trust verification). The trust +list applies on every adoption path, +including the legacy fallback where a server without protected-resource metadata acts +as its own authorization server — with a trust list configured, list the server's +base URL to allow that arrangement (hooks can tell the fallback apart by +`ctx.source === 'sdk-derived'`). `fetchToken()` adopts no authorization server — the +caller supplies its URL — so the trust list does not apply there; of these members it +honors only the `validateDiscoveryURL` hook, at the token-endpoint checkpoint. +The `validateDiscoveryURL` hook runs after the +mechanical policy at **every** checkpoint: before each discovery, token, and +registration request (including each followed redirect hop) and at each assert-only +adoption (authorization-server adoption, cached-state restore, and the authorization +endpoint before the user agent is redirected) — filter on `ctx.purpose` to scope a +check. A hook throw that is not a `DiscoveryUrlBlockedError` is wrapped into one +(original error as `cause`), so callers observe a single failure type. To +restore the previous behavior wholesale, set all three `discoveryPolicy` flags — but +prefer enabling only the specific override a deployment needs, since each one weakens a +conformance check. + +#### Transport request headers no longer applied to OAuth requests (`oauthRequestInit`) + +In v1, the `requestInit` option on `StreamableHTTPClientTransport` and +`SSEClientTransport` was merged into **every** request the transport made — including +the protected-resource metadata, authorization-server metadata, token, and client +registration requests driven by the authorization flow, which can target a different +origin than the MCP server. In v2, `requestInit` applies to MCP data-plane traffic +only; authorization requests are issued through the bare `fetch` (the `fetch` option +still applies to all requests, so proxy agents, dispatchers, and tracing wrappers keep +working). Deployments that need extra configuration on authorization requests — e.g. +gateway headers on well-known endpoints — set the new `oauthRequestInit` transport +option, which is merged into authorization requests only: + +```ts +const transport = new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: { headers: { 'X-Api-Key': mcpGatewayKey } }, // MCP requests only + oauthRequestInit: { headers: { 'X-Api-Key': authGatewayKey } } // OAuth requests only +}); +``` + +`oauthRequestInit` headers follow the same per-hop rule as the rest of the discovery +path: when a discovery request is redirected to a different origin, the redirected +request is issued without them — headers never travel across origins on a redirect. + #### Scope step-up on `403 insufficient_scope` (SEP-2350) `StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'` diff --git a/packages/client/eslint.config.mjs b/packages/client/eslint.config.mjs index dd9e88588a..48b1acc7f1 100644 --- a/packages/client/eslint.config.mjs +++ b/packages/client/eslint.config.mjs @@ -8,5 +8,28 @@ export default [ settings: { 'import/internal-regex': '^@modelcontextprotocol/core-internal' } + }, + { + // Every request in the OAuth client flow (discovery GETs, token and + // registration POSTs — including the Cross-App Access token exchanges) + // must pass the URL policy (assertAllowedDiscoveryUrl) before any network + // I/O. fetchDiscoveryUrl applies it to the initial URL and to every + // redirect hop, so it is the only place raw fetch calls live. + files: ['src/client/auth.ts', 'src/client/crossAppAccess.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.name=/^(fetch|fetchFn)$/]:not(FunctionDeclaration[id.name='fetchDiscoveryUrl'] *)", + message: + 'Route OAuth-flow requests through fetchDiscoveryUrl so the URL policy (assertAllowedDiscoveryUrl) and manual redirect handling apply before any request.' + }, + { + selector: "CallExpression[callee.type='LogicalExpression'][callee.left.name='fetchFn'][callee.right.name='fetch']", + message: + 'Direct (fetchFn ?? fetch)(...) calls bypass the URL policy; route the request through fetchDiscoveryUrl instead.' + } + ] + } } ]; diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..067ce322d9 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,6 +1,11 @@ import { CORS_IS_POSSIBLE } from '@modelcontextprotocol/client/_shims'; import type { AuthorizationServerMetadata, + DiscoveryUrlContext, + DiscoveryUrlPolicyOptions, + DiscoveryUrlProducer, + DiscoveryUrlPurpose, + DiscoveryUrlSource, FetchLike, OAuthClientInformation, OAuthClientInformationFull, @@ -13,8 +18,10 @@ import type { StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; import { + assertAllowedDiscoveryUrl, brandedHasInstance, checkResourceAllowed, + DiscoveryUrlBlockedError, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, @@ -23,13 +30,20 @@ import { OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema, + OMIT_BASE_HEADERS, OpenIdProviderDiscoveryMetadataSchema, resourceUrlFromServerUrl, stampErrorBrands } from '@modelcontextprotocol/core-internal'; import pkceChallenge from 'pkce-challenge'; -import { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors'; +import { + AuthorizationServerMismatchError, + InsecureTokenEndpointError, + IssuerMismatchError, + RedirectFilteredResponseError, + RegistrationRejectedError +} from './authErrors'; // Re-exported for back-compat — the canonical home is ./authErrors.js. export { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors'; @@ -150,7 +164,8 @@ export function discardIfIssuerMismatch( * validation in {@linkcode discoverAuthorizationServerMetadata}: when the SDK * derives an issuer from `String(new URL(...))` (always slash-suffixed) and the AS * publishes a slash-free `metadata.issuer`, the two name the same authorization - * server. + * server. Also the comparison behind {@linkcode OAuthClientProvider.trustedAuthorizationServers} + * entry matching. */ function issuersMatch(a: string, b: string): boolean { return a === b || (a.endsWith('/') && a.slice(0, -1) === b) || (b.endsWith('/') && b.slice(0, -1) === a); @@ -183,6 +198,10 @@ export async function handleOAuthUnauthorized( const result = await auth(provider, { serverUrl: ctx.serverUrl, resourceMetadataUrl, + // Relayed from the challenge: a URL-policy rejection falls back to the + // well-known derivation instead of failing the flow — see + // {@linkcode ResourceMetadataUrlSource}. + resourceMetadataUrlSource: 'www-authenticate', scope, fetchFn: ctx.fetchFn, ...extraAuthOptions @@ -350,6 +369,69 @@ export interface OAuthClientProvider { */ validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; + /** + * Trust verification hook for every URL the OAuth flow validates: called AFTER + * a URL has passed the SDK's own URL policy ({@linkcode assertAllowedDiscoveryUrl}), + * at every checkpoint — + * + * - before each request the flow issues (discovery GETs, token and registration + * POSTs), including each manually-followed redirect `Location` target + * (`ctx.purpose === 'redirect-hop'`), and + * - at the assert-only sites where a URL is adopted rather than requested: + * authorization-server adoption per RFC 9728 §7.6 (fresh discovery, the legacy + * server-derived fallback, and restore from cached discovery state — the + * context's `source` says which) and the authorization endpoint the user agent + * is about to be redirected to. + * + * Filter on `ctx.purpose`/`ctx.source` to scope a check to specific steps. + * Throw to reject the URL and fail the flow closed; return normally to accept + * it. A rejection always surfaces to callers as + * {@linkcode DiscoveryUrlBlockedError} — a throw of any other type is wrapped + * (with the original error as `cause`), so there is a single failure type for + * every URL-policy or trust rejection. The SDK's mechanical policy checks URL + * structure and locality only — implement this hook (or + * {@linkcode OAuthClientProvider.trustedAuthorizationServers | trustedAuthorizationServers}) + * to verify the URLs the flow contacts are ones the application trusts. + */ + validateDiscoveryURL?(ctx: DiscoveryUrlContext): void | Promise; + + /** + * Declarative RFC 9728 §7.6 trust list for authorization servers. Entries are + * issuer identifiers: an adopted authorization server must match an entry on the + * full identifier, tolerating only a trailing-slash difference. A + * path-distinguished issuer (e.g. `https://as.example.com/tenant1`) is matched + * exactly — listing one tenant does not trust another on the same host, and an + * origin-only entry does not cover pathed issuers on that host. The list is + * exhaustive, not additive: an authorization server matching no entry is rejected + * with {@linkcode DiscoveryUrlBlockedError}, even when it passes every other rule. + * This includes the legacy fallback where the MCP server acts as its own + * authorization server — list the server's base URL to allow that arrangement. + * + * The list is enforced where the flow adopts an authorization server + * (`auth()` and the transports, including restore from cached discovery + * state). {@linkcode fetchToken} takes a caller-supplied authorization + * server and adopts nothing, so it does not consult the list — on that path + * only the {@linkcode OAuthClientProvider.validateDiscoveryURL | validateDiscoveryURL} + * hook runs, at the token-endpoint checkpoint. + */ + trustedAuthorizationServers?: (string | URL)[]; + + /** + * Overrides for the URL policy applied to every discovery-derived URL before + * it is requested or adopted ({@linkcode assertAllowedDiscoveryUrl}) — see + * {@linkcode DiscoveryUrlPolicyOptions} for what each option relaxes. All + * options are **policy-weakening** and default to off (the policy fails + * closed); rejection messages name the option that would have permitted the + * URL. + * + * This member is the durable home for a deployment's policy: every flow the + * SDK drives with this provider — including the flows the client transports + * run on `401`/`403` challenges and on `finishAuth` — reads it. A per-call + * {@linkcode AuthOptions.discoveryPolicy} takes precedence when both are + * set; the effective policy is resolved once at flow entry. + */ + readonly discoveryPolicy?: DiscoveryUrlPolicyOptions; + /** * If implemented, provides a way for the client to invalidate (e.g. delete) the specified * credentials, in the case where the server has indicated that they are no longer valid. @@ -487,6 +569,25 @@ export interface OAuthDiscoveryState extends OAuthServerInfo { export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; +/** + * Provenance of an explicitly-supplied protected-resource-metadata URL + * ({@linkcode AuthOptions.resourceMetadataUrl}). It decides how a URL-policy + * rejection of that URL is handled: + * + * - `'www-authenticate'` — relayed from a server's `WWW-Authenticate` challenge + * (the transports label the URLs they extract this way). A rejected URL is + * ignored with a warning and discovery falls back to the SDK's own same-origin + * well-known derivation (RFC 9728 §3): non-conformant relayed input does not + * take the flow down when the SDK can derive the metadata location itself. + * - `'caller'` (the default) — configured by the application. A rejected URL is a + * configuration statement the policy contradicts, so the flow fails closed with + * {@linkcode DiscoveryUrlBlockedError} instead of silently discovering at a + * different location. A URL restored from a provider's persisted + * {@linkcode OAuthDiscoveryState} is handled the same way: it is re-validated + * on every restore and a rejection fails closed. + */ +export type ResourceMetadataUrlSource = Extract; + export class UnauthorizedError extends Error { static { Object.defineProperty(this, 'mcpBrand', { value: 'mcp.UnauthorizedError' }); @@ -659,7 +760,13 @@ export async function resolveAuthorizationCallbackParams( iss: string | undefined, provider: OAuthClientProvider, serverUrl: string | URL, - opts?: { fetchFn?: FetchLike; resourceMetadataUrl?: URL } + opts?: { + fetchFn?: FetchLike; + resourceMetadataUrl?: URL; + /** Provenance of `resourceMetadataUrl` — see {@linkcode ResourceMetadataUrlSource}. */ + resourceMetadataUrlSource?: ResourceMetadataUrlSource; + discoveryPolicy?: DiscoveryUrlPolicyOptions; + } ): Promise<{ authorizationCode: string; iss: string | undefined }> { if (typeof codeOrParams === 'string') { return { authorizationCode: codeOrParams, iss }; @@ -676,7 +783,20 @@ export async function resolveAuthorizationCallbackParams( let metadata = discoveryState?.authorizationServerMetadata; if (!metadata) { try { - const serverInfo = await discoverOAuthServerInfo(serverUrl, opts); + // Effective policy resolved once at this flow entry, like authInternal's; + // the provider's RFC 9728 §7.6 trust members apply to the adoption exactly + // as on the main flow. + const serverInfo = await discoverOAuthServerInfoInternal( + serverUrl, + { + ...opts, + discoveryPolicy: opts?.discoveryPolicy ?? provider.discoveryPolicy + }, + { + trustedAuthorizationServers: provider.trustedAuthorizationServers, + validateDiscoveryURL: provider.validateDiscoveryURL?.bind(provider) + } + ); metadata = serverInfo.authorizationServerMetadata; } catch { metadata = undefined; @@ -951,10 +1071,32 @@ export interface AuthOptions { iss?: string; /** Scope to request; computed by Scope Selection Strategy when omitted. */ scope?: string; - /** Explicit `resource_metadata` URL from a `WWW-Authenticate` challenge. */ + /** + * Explicit protected-resource-metadata URL, bypassing the RFC 9728 §3 + * well-known derivation. Callers relaying a `WWW-Authenticate` challenge's + * `resource_metadata` value should also set + * {@linkcode AuthOptions.resourceMetadataUrlSource | resourceMetadataUrlSource}. + */ resourceMetadataUrl?: URL; + /** + * Where `resourceMetadataUrl` came from — see + * {@linkcode ResourceMetadataUrlSource} for how each value handles a + * URL-policy rejection. Callers relaying a `WWW-Authenticate` challenge + * (as the SDK transports do) set `'www-authenticate'`; the default + * `'caller'` treats a rejected URL as a configuration error and fails + * closed. + */ + resourceMetadataUrlSource?: ResourceMetadataUrlSource; /** Custom `fetch` implementation. */ fetchFn?: FetchLike; + /** + * Overrides for the URL policy applied to every discovery-derived URL before it + * is requested or adopted ({@linkcode assertAllowedDiscoveryUrl}). All options + * are **policy-weakening** and default to off. Takes precedence over + * {@linkcode OAuthClientProvider.discoveryPolicy} when both are set; the + * effective policy is resolved once at flow entry. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; /** * Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization * server discovery. Disabling it is **security-weakening** and intended only @@ -1056,11 +1198,24 @@ async function authInternal( iss, scope, resourceMetadataUrl, + resourceMetadataUrlSource, fetchFn, + discoveryPolicy: discoveryPolicyOverride, skipIssuerMetadataValidation, forceReauthorization }: AuthOptions ): Promise { + // The effective URL policy for the whole flow, resolved once at entry: + // per-call options take precedence over the provider's durable policy. + const discoveryPolicy = discoveryPolicyOverride ?? provider.discoveryPolicy; + + // RFC 9728 §7.6 trust, resolved once at entry. Trust flows only from the + // provider — there is no per-call override. + const trust: AuthorizationServerTrust = { + trustedAuthorizationServers: provider.trustedAuthorizationServers, + validateDiscoveryURL: provider.validateDiscoveryURL?.bind(provider) + }; + // SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope() // intentionally reads the raw provider.clientMetadata instead. const clientMetadata = resolveClientMetadata(provider); @@ -1073,36 +1228,79 @@ async function authInternal( let metadata: AuthorizationServerMetadata | undefined; let freshDiscoveryState: OAuthDiscoveryState | undefined; - // If resourceMetadataUrl is not provided, try to load it from cached state - // This handles browser redirects where the URL was saved before navigation + // If resourceMetadataUrl is not provided, try to load it from cached state. + // This handles browser redirects where the URL was saved before navigation. + // A URL restored from cache is handled like a caller-configured one: it is + // re-validated on every restore and a policy rejection fails closed rather + // than discovering at a different location. let effectiveResourceMetadataUrl = resourceMetadataUrl; + let effectiveResourceMetadataUrlSource = resourceMetadataUrlSource ?? 'caller'; if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + effectiveResourceMetadataUrlSource = 'caller'; } + // Only a caller-configured metadata URL is recorded in discovery state (a + // configuration statement, restored and re-validated as such above). A + // challenge-relayed URL is re-extracted from each 401 — and may have been + // set aside in favor of the SDK's own well-known derivation (see + // discoverMetadataWithFallback) — so recording it could persist a URL the + // flow never used and that a restore would then treat as caller-configured. + const persistedResourceMetadataUrl = + effectiveResourceMetadataUrlSource === 'caller' ? effectiveResourceMetadataUrl?.toString() : undefined; + if (cachedState?.authorizationServerUrl) { - // Restore discovery state from cache + // Restore discovery state from cache. The cached authorization server URL is + // network-derived state, so it is re-validated on every restore exactly like a + // fresh adoption — otherwise state persisted before a policy or trust-list + // change would keep bypassing RFC 9728 §7.6 verification. A rejected cached + // URL throws DiscoveryUrlBlockedError. authorizationServerUrl = cachedState.authorizationServerUrl; + const cachedUrlContext: DiscoveryUrlContext = { + purpose: 'authorization-server', + url: new URL(authorizationServerUrl), + producer: { url: new URL(serverUrl), kind: 'mcp-server' }, + source: 'cached-discovery-state' + }; + assertAllowedDiscoveryUrl(cachedUrlContext, discoveryPolicy); + await verifyAuthorizationServerTrust(cachedUrlContext, trust); resourceMetadata = cachedState.resourceMetadata; metadata = cachedState.authorizationServerMetadata ?? - (await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn, - skipIssuerValidation: skipIssuerMetadataValidation - })); + (await discoverAuthorizationServerMetadataInternal( + authorizationServerUrl, + { + fetchFn, + skipIssuerValidation: skipIssuerMetadataValidation, + discoveryPolicy + }, + trust.validateDiscoveryURL + )); // If resource metadata wasn't cached, try to fetch it for selectResourceURL if (!resourceMetadata) { try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( + resourceMetadata = await discoverOAuthProtectedResourceMetadataInternal( serverUrl, - { resourceMetadataUrl: effectiveResourceMetadataUrl }, - fetchFn + { + resourceMetadataUrl: effectiveResourceMetadataUrl, + resourceMetadataUrlSource: effectiveResourceMetadataUrlSource, + discoveryPolicy + }, + fetchFn, + trust.validateDiscoveryURL ); } catch (error) { // Network failures (DNS, connection refused) surface as TypeError — propagate - // those rather than masking a transient reachability problem. - if (error instanceof TypeError) { + // those rather than masking a transient reachability problem. URL-policy + // rejections likewise fail closed instead of proceeding without metadata, as + // does a runtime-filtered redirect on a caller-configured metadata URL (on an + // SDK-derived probe it degrades below, exactly like a 404). + if ( + error instanceof TypeError || + error instanceof DiscoveryUrlBlockedError || + isFilteredCallerMetadataUrl(error, effectiveResourceMetadataUrl, effectiveResourceMetadataUrlSource) + ) { throw error; } // RFC 9728 not available — selectResourceURL will handle undefined @@ -1113,18 +1311,24 @@ async function authInternal( if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { await provider.saveDiscoveryState?.({ authorizationServerUrl: String(authorizationServerUrl), - resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadataUrl: persistedResourceMetadataUrl, resourceMetadata, authorizationServerMetadata: metadata }); } } else { // Full discovery via RFC 9728 - const serverInfo = await discoverOAuthServerInfo(serverUrl, { - resourceMetadataUrl: effectiveResourceMetadataUrl, - fetchFn, - skipIssuerMetadataValidation - }); + const serverInfo = await discoverOAuthServerInfoInternal( + serverUrl, + { + resourceMetadataUrl: effectiveResourceMetadataUrl, + resourceMetadataUrlSource: effectiveResourceMetadataUrlSource, + fetchFn, + skipIssuerMetadataValidation, + discoveryPolicy + }, + trust + ); authorizationServerUrl = serverInfo.authorizationServerUrl; metadata = serverInfo.authorizationServerMetadata; resourceMetadata = serverInfo.resourceMetadata; @@ -1137,7 +1341,7 @@ async function authInternal( // discoverOAuthProtectedResourceMetadata() is not captured back here. freshDiscoveryState = { authorizationServerUrl: String(authorizationServerUrl), - resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadataUrl: persistedResourceMetadataUrl, resourceMetadata, authorizationServerMetadata: metadata }; @@ -1247,12 +1451,17 @@ async function authInternal( throw new Error('OAuth client information must be saveable for dynamic registration'); } - const fullInformation = await registerClient(authorizationServerUrl, { - metadata, - clientMetadata, - scope: resolvedScope, - fetchFn - }); + const fullInformation = await registerClientInternal( + authorizationServerUrl, + { + metadata, + clientMetadata, + scope: resolvedScope, + fetchFn, + discoveryPolicy + }, + trust.validateDiscoveryURL + ); clientInformation = { ...fullInformation, issuer }; await provider.saveClientInformation(clientInformation, infoCtx); @@ -1281,7 +1490,8 @@ async function authInternal( authorizationCode, iss, scope: resolvedScope, - fetchFn + fetchFn, + discoveryPolicy }); await provider.saveTokens({ ...tokens, issuer }, infoCtx); @@ -1305,22 +1515,32 @@ async function authInternal( if (tokens?.refresh_token && !forceReauthorization) { try { // Attempt to refresh the token - const newTokens = await refreshAuthorization(authorizationServerUrl, { - metadata, - clientInformation, - refreshToken: tokens.refresh_token, - resource, - addClientAuthentication: provider.addClientAuthentication, - fetchFn - }); + const newTokens = await refreshAuthorizationInternal( + authorizationServerUrl, + { + metadata, + clientInformation, + refreshToken: tokens.refresh_token, + resource, + addClientAuthentication: provider.addClientAuthentication, + fetchFn, + discoveryPolicy + }, + trust.validateDiscoveryURL + ); await provider.saveTokens({ ...newTokens, issuer }, infoCtx); return 'AUTHORIZED'; } catch (error) { - // A non-TLS token endpoint is a configuration error — re-authorizing cannot - // fix it. Surface it so the consumer sees the misconfiguration instead of an - // unexplained re-auth prompt. - if (error instanceof InsecureTokenEndpointError) { + // A non-TLS, policy-blocked, or redirecting token endpoint is a + // configuration error — re-authorizing cannot fix it (the exchange + // POST targets the same endpoint). Surface it so the consumer sees + // the misconfiguration instead of an unexplained re-auth prompt. + if ( + error instanceof InsecureTokenEndpointError || + error instanceof DiscoveryUrlBlockedError || + error instanceof RedirectFilteredResponseError + ) { throw error; } // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. @@ -1336,14 +1556,19 @@ async function authInternal( const state = provider.state ? await provider.state() : undefined; // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { - metadata, - clientInformation, - state, - redirectUrl: provider.redirectUrl, - scope: resolvedScope, - resource - }); + const { authorizationUrl, codeVerifier } = await startAuthorizationInternal( + authorizationServerUrl, + { + metadata, + clientInformation, + state, + redirectUrl: provider.redirectUrl, + scope: resolvedScope, + resource, + discoveryPolicy + }, + trust.validateDiscoveryURL + ); await provider.saveCodeVerifier(codeVerifier); await provider.redirectToAuthorization(authorizationUrl); @@ -1518,12 +1743,46 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { */ export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, - opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL }, + opts?: { + protocolVersion?: string; + resourceMetadataUrl?: string | URL; + /** + * Where `resourceMetadataUrl` came from — see + * {@linkcode ResourceMetadataUrlSource}. Defaults to `'caller'`, which + * fails closed on a URL-policy rejection; `'www-authenticate'` (a relayed + * challenge value) warns and falls back to the well-known derivation. + */ + resourceMetadataUrlSource?: ResourceMetadataUrlSource; + /** + * Overrides for the URL policy applied before each discovery request + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; + }, fetchFn: FetchLike = fetch +): Promise { + return discoverOAuthProtectedResourceMetadataInternal(serverUrl, opts, fetchFn); +} + +/** + * {@linkcode discoverOAuthProtectedResourceMetadata} with the provider's + * `validateDiscoveryURL` hook threaded by the calling flow entry + * ({@linkcode auth} / a transport). Trust flows only from the provider, so the + * hook rides this internal variant rather than the public signature — direct + * callers configure it on the provider they hand to those entries. + */ +async function discoverOAuthProtectedResourceMetadataInternal( + serverUrl: string | URL, + opts: Parameters[1], + fetchFn: FetchLike = fetch, + validateDiscoveryURL?: ValidateDiscoveryUrlHook ): Promise { const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, { protocolVersion: opts?.protocolVersion, - metadataUrl: opts?.resourceMetadataUrl + metadataUrl: opts?.resourceMetadataUrl, + metadataUrlSource: opts?.resourceMetadataUrlSource, + discoveryPolicy: opts?.discoveryPolicy, + validate: validateDiscoveryURL }); if (!response || response.status === 404) { @@ -1556,9 +1815,14 @@ export async function discoverOAuthProtectedResourceMetadata( * In browsers, we cannot reliably distinguish CORS `TypeError` from network `TypeError` from the * error object alone, so the swallow-and-fallthrough heuristic is preserved there. */ -async function fetchWithCorsRetry(url: URL, headers?: Record, fetchFn: FetchLike = fetch): Promise { +async function fetchWithCorsRetry( + url: URL, + headers: Record | undefined, + fetchFn: FetchLike, + gate: DiscoveryFetchGate +): Promise { try { - return await fetchFn(url, { headers }); + return await fetchDiscoveryUrl(url, { headers }, fetchFn, gate); } catch (error) { if (!(error instanceof TypeError) || !CORS_IS_POSSIBLE) { throw error; @@ -1567,7 +1831,7 @@ async function fetchWithCorsRetry(url: URL, headers?: Record, fe // Could be a CORS preflight rejection caused by our custom header. Retry as a simple // request: if that succeeds, we've sidestepped the preflight. try { - return await fetchFn(url, {}); + return await fetchDiscoveryUrl(url, undefined, fetchFn, gate); } catch (retryError) { if (!(retryError instanceof TypeError)) { throw retryError; @@ -1581,6 +1845,174 @@ async function fetchWithCorsRetry(url: URL, headers?: Record, fe } } +/** Redirect status codes eligible for the bounded manual follow on discovery requests. */ +const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); + +/** Maximum number of `Location` hops followed on a discovery request. */ +const MAX_DISCOVERY_REDIRECT_HOPS = 3; + +/** + * URL-policy context threaded through the discovery fetch helpers so that the + * initial URL and every redirect target are validated with the originating + * purpose on record. + * + * @internal Not part of the public barrel; shared with the Cross-App Access + * helpers so their token requests ride the same validated path. + */ +export interface DiscoveryFetchGate { + purpose: Exclude; + source: DiscoveryUrlSource; + producer: DiscoveryUrlProducer; + policy?: DiscoveryUrlPolicyOptions; + /** + * The provider's {@linkcode OAuthClientProvider.validateDiscoveryURL} hook, + * threaded here by the flow entry so the fetch funnel stays provider-agnostic. + * Runs after the mechanical policy on the initial URL and on every redirect hop. + */ + validate?: ValidateDiscoveryUrlHook; +} + +/** + * Request shape accepted by {@linkcode fetchDiscoveryUrl}. Defaults to a GET. + * + * @internal Not part of the public barrel. + */ +export interface DiscoveryRequestInit { + method?: 'GET' | 'POST'; + headers?: RequestInit['headers']; + body?: RequestInit['body']; +} + +/** + * Performs an OAuth-flow request with the URL policy applied before any network + * I/O: the URL is validated with the gate's purpose + * ({@linkcode assertAllowedDiscoveryUrl}) before the first request, so every + * request routed through this helper is gated by construction. All requests are + * issued with `redirect: 'manual'`. + * + * GET requests get a bounded manual follow: each `Location` target is validated + * with purpose `'redirect-hop'` before it is requested, headers are re-derived + * per hop and dropped when a hop leaves the current origin (including base + * headers a `createFetchWithInit` wrapper would otherwise re-apply — see + * `OMIT_BASE_HEADERS` on the internal barrel), and at most + * `MAX_DISCOVERY_REDIRECT_HOPS` (3) hops are followed. A redirect that cannot + * be followed (no `Location` or hop budget exhausted) is returned as-is so the + * caller's error handling applies — except a runtime-filtered redirect (browser + * opaque redirect), which fails with `RedirectFilteredResponseError`; see + * `throwIfRedirectFiltered` below for the mechanism. + * + * POST requests (token, registration) are never re-sent to a `Location` target: + * token responses are terminal (RFC 6749 §5), so a redirect status is returned + * un-followed and fails the caller's status handling instead. + * + * @internal Not part of the public barrel; exported for the Cross-App Access + * helpers, which issue their token-exchange POSTs through this same path. + */ +export async function fetchDiscoveryUrl( + url: URL, + init: DiscoveryRequestInit | undefined, + fetchFn: FetchLike, + gate: DiscoveryFetchGate +): Promise { + const initialContext: DiscoveryUrlContext = { purpose: gate.purpose, url, producer: gate.producer, source: gate.source }; + assertAllowedDiscoveryUrl(initialContext, gate.policy); + await invokeValidateDiscoveryUrlHook(initialContext, gate.validate); + const method = init?.method ?? 'GET'; + let currentUrl = url; + let currentHeaders = init?.headers; + let response = await fetchFn(currentUrl, { method, headers: currentHeaders, body: init?.body, redirect: 'manual' }); + throwIfRedirectFiltered(response, currentUrl, gate, method); + if (method !== 'GET') { + return response; + } + for (let hop = 0; REDIRECT_STATUS_CODES.has(response.status); hop++) { + const location = response.headers.get('location'); + if (location === null || hop >= MAX_DISCOVERY_REDIRECT_HOPS) { + return response; + } + let nextUrl: URL; + try { + nextUrl = new URL(location, currentUrl); + } catch { + // An unparsable Location cannot be followed; surface the redirect response + // itself so the caller's HTTP error handling reports it (a URL-parse + // TypeError would be misread as a cross-origin fetch rejection by the + // retry heuristic in fetchWithCorsRetry). + return response; + } + const hopContext: DiscoveryUrlContext = { + purpose: 'redirect-hop', + url: nextUrl, + // A hop is judged against the producer of the request being followed. + producer: gate.producer, + source: gate.source, + redirectHop: { from: currentUrl, status: response.status, originalPurpose: gate.purpose } + }; + assertAllowedDiscoveryUrl(hopContext, gate.policy); + await invokeValidateDiscoveryUrlHook(hopContext, gate.validate); + // Release the intermediate response before following the hop. + await response.text?.().catch(() => {}); + if (nextUrl.origin !== currentUrl.origin) { + // Headers never travel across origins on a redirect. The sentinel (rather + // than plain `undefined`) also keeps a createFetchWithInit wrapper — the + // transports' `oauthRequestInit` fetch — from falling back to its base + // headers on the cross-origin request. + currentHeaders = OMIT_BASE_HEADERS; + } + currentUrl = nextUrl; + response = await fetchFn(currentUrl, { method, headers: currentHeaders, redirect: 'manual' }); + throwIfRedirectFiltered(response, currentUrl, gate, method, true); + } + return response; +} + +/** + * Fails a request whose response is a redirect the runtime filtered. Browser + * runtimes resolve a `redirect: 'manual'` fetch whose response status is a + * redirect with an opaque redirect (Fetch `Response.type === 'opaqueredirect'`: + * status 0, no readable `Location` header), so the redirect target cannot be + * observed. {@linkcode fetchDiscoveryUrl} checks every redirect target before + * following it; a target that cannot be observed cannot be checked, so a + * filtered redirect fails with `RedirectFilteredResponseError` instead of + * surfacing as an unexplained `HTTP 0` status. When the filtered response + * answers a followed hop, the error reports purpose `'redirect-hop'` and + * carries `redirectHop` (`from` is the URL that answered with the filtered + * redirect, `status` is the filtered response's literal `0`), matching the + * context shape of other hop-stage rejections. + * + * How callers handle the failure depends on what the request was for: the + * SDK-derived well-known probes treat it as "not retrievably served" and + * degrade exactly like a 404 (see {@linkcode discoverOAuthServerInfo}'s legacy + * fallback and the {@linkcode discoverAuthorizationServerMetadata} candidate + * loop), a challenge-relayed metadata URL falls back to the well-known + * derivation with a warning (see `discoverMetadataWithFallback`), and a + * caller-configured metadata URL or a token/registration POST fails. Redirected + * POST responses are never followed in any runtime — token responses are + * terminal (RFC 6749 §5) — so for POSTs the message points at the endpoint + * configuration rather than the runtime. + */ +function throwIfRedirectFiltered( + response: Response, + url: URL, + gate: DiscoveryFetchGate, + method: 'GET' | 'POST', + followedHop = false +): void { + if (response.type !== 'opaqueredirect') { + return; + } + const detail = + method === 'POST' + ? 'redirected responses to this request are never followed, in any runtime (token responses are terminal, RFC 6749 §5); serve this endpoint without a redirect' + : 'serve the document at this URL without a redirect, or run the flow in a runtime that exposes redirect responses (a redirect target that passes validation is then followed)'; + throw new RedirectFilteredResponseError( + url, + followedHop ? 'redirect-hop' : gate.purpose, + detail, + followedHop ? { from: url, status: 0, originalPurpose: gate.purpose } : undefined + ); +} + /** * Constructs the well-known path for auth-related metadata discovery */ @@ -1600,11 +2032,16 @@ function buildWellKnownPath( /** * Tries to discover OAuth metadata at a specific URL */ -async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: FetchLike = fetch): Promise { +async function tryMetadataDiscovery( + url: URL, + protocolVersion: string, + fetchFn: FetchLike = fetch, + gate: DiscoveryFetchGate +): Promise { const headers = { 'MCP-Protocol-Version': protocolVersion }; - return await fetchWithCorsRetry(url, headers, fetchFn); + return await fetchWithCorsRetry(url, headers, fetchFn, gate); } /** @@ -1623,29 +2060,107 @@ async function discoverMetadataWithFallback( serverUrl: string | URL, wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', fetchFn: FetchLike, - opts?: { protocolVersion?: string; metadataUrl?: string | URL; metadataServerUrl?: string | URL } + opts?: { + protocolVersion?: string; + metadataUrl?: string | URL; + /** Provenance of `metadataUrl`; picks the rejection handling below. */ + metadataUrlSource?: ResourceMetadataUrlSource; + metadataServerUrl?: string | URL; + discoveryPolicy?: DiscoveryUrlPolicyOptions; + /** The provider's hook, run at each fetch checkpoint (see {@linkcode DiscoveryFetchGate.validate}). */ + validate?: ValidateDiscoveryUrlHook; + } ): Promise { - const issuer = new URL(serverUrl); const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + const purpose: DiscoveryFetchGate['purpose'] = wellKnownType === 'oauth-protected-resource' ? 'resource-metadata' : 'as-metadata'; + // Protected-resource metadata is produced by the MCP server itself; the + // authorization-server well-known lookup (the deprecated direct path) is + // anchored on the authorization server whose metadata is being fetched. + const producer: DiscoveryUrlProducer = { + url: new URL(serverUrl), + kind: wellKnownType === 'oauth-protected-resource' ? 'mcp-server' : 'authorization-server' + }; - let url: URL; if (opts?.metadataUrl) { - url = new URL(opts.metadataUrl); - } else { - // Try path-aware discovery first - const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); - url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer); - url.search = issuer.search; + // An explicit metadata URL is validated before it is requested, and its + // provenance picks the handling of a non-usable value. A URL relayed + // from a WWW-Authenticate challenge is network-derived input: when it is + // non-conformant — most commonly not same-origin with the server + // (RFC 9728 §3) — or when its response is a redirect the runtime + // filters, the URL is ignored with a warning and discovery falls back to + // the SDK's own well-known derivation below, identical to the path taken + // when no metadata URL is supplied at all. (A rejection from the + // provider's validateDiscoveryURL hook is not covered — it propagates.) + // A caller-configured URL ('caller', the default) is a configuration + // statement: rejecting it fails closed rather than silently discovering + // at a different location. + const explicitSource = opts.metadataUrlSource ?? 'caller'; + const explicitUrl = new URL(opts.metadataUrl); + let explicitUrlUsable = true; + try { + assertAllowedDiscoveryUrl({ purpose, url: explicitUrl, producer, source: explicitSource }, opts?.discoveryPolicy); + } catch (error) { + if (!(error instanceof DiscoveryUrlBlockedError) || explicitSource !== 'www-authenticate') { + throw error; + } + explicitUrlUsable = false; + console.warn(`[mcp-sdk] Ignoring the provided metadata URL and using well-known discovery instead: ${error.message}`); + } + if (explicitUrlUsable) { + const gate: DiscoveryFetchGate = { + purpose, + source: explicitSource, + producer, + policy: opts?.discoveryPolicy, + validate: opts?.validate + }; + try { + return await tryMetadataDiscovery(explicitUrl, protocolVersion, fetchFn, gate); + } catch (error) { + if (!(error instanceof RedirectFilteredResponseError) || explicitSource !== 'www-authenticate') { + throw error; + } + console.warn(`[mcp-sdk] Ignoring the provided metadata URL and using well-known discovery instead: ${error.message}`); + } + } } - let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); + // Try path-aware discovery first + const wellKnownPath = buildWellKnownPath(wellKnownType, producer.url.pathname); + const url = new URL(wellKnownPath, opts?.metadataServerUrl ?? producer.url); + url.search = producer.url.search; + + const gate: DiscoveryFetchGate = { purpose, source: 'sdk-derived', producer, policy: opts?.discoveryPolicy, validate: opts?.validate }; + + // On the SDK-derived probes, a runtime-filtered redirect means the document + // is not retrievably served at that URL — the same situation as a 404, so it + // is fallback-eligible the same way. It is recorded rather than returned + // (there is no response to return) and surfaces only when no probe answers. + let filtered: RedirectFilteredResponseError | undefined; + const probe = async (probeUrl: URL): Promise => { + filtered = undefined; + try { + return await tryMetadataDiscovery(probeUrl, protocolVersion, fetchFn, gate); + } catch (error) { + if (!(error instanceof RedirectFilteredResponseError)) { + throw error; + } + filtered = error; + return undefined; + } + }; + + let response = await probe(url); // If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery - if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { - const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); - response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); + if (producer.url.pathname !== '/' && (filtered !== undefined || shouldAttemptFallback(response, producer.url.pathname))) { + const rootUrl = new URL(`/.well-known/${wellKnownType}`, producer.url); + response = await probe(rootUrl); } + if (filtered !== undefined) { + throw filtered; + } return response; } @@ -1655,16 +2170,28 @@ async function discoverMetadataWithFallback( * If the server returns a 404 for the well-known endpoint, this function will * return `undefined`. Any other errors will be thrown as exceptions. * + * Each candidate URL is checked against the discovery URL policy + * ({@linkcode assertAllowedDiscoveryUrl}) before it is requested; a rejected + * URL fails the lookup with {@linkcode DiscoveryUrlBlockedError}. Use + * `discoveryPolicy` to override individual rules, as with + * {@linkcode discoverAuthorizationServerMetadata}. + * * @deprecated This function is deprecated in favor of {@linkcode discoverAuthorizationServerMetadata}. */ export async function discoverOAuthMetadata( issuer: string | URL, { authorizationServerUrl, - protocolVersion + protocolVersion, + discoveryPolicy }: { authorizationServerUrl?: string | URL; protocolVersion?: string; + /** + * Overrides for the URL policy applied before each discovery request + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } = {}, fetchFn: FetchLike = fetch ): Promise { @@ -1681,7 +2208,8 @@ export async function discoverOAuthMetadata( const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, { protocolVersion, - metadataServerUrl: authorizationServerUrl + metadataServerUrl: authorizationServerUrl, + discoveryPolicy }); if (!response || response.status === 404) { @@ -1786,15 +2314,36 @@ export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: */ export async function discoverAuthorizationServerMetadata( authorizationServerUrl: string | URL, - { - fetchFn = fetch, - protocolVersion = LATEST_PROTOCOL_VERSION, - skipIssuerValidation = false - }: { + options: { fetchFn?: FetchLike; protocolVersion?: string; skipIssuerValidation?: boolean; + /** + * Overrides for the URL policy applied before each discovery request + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } = {} +): Promise { + return discoverAuthorizationServerMetadataInternal(authorizationServerUrl, options); +} + +/** + * {@linkcode discoverAuthorizationServerMetadata} with the provider's + * `validateDiscoveryURL` hook threaded by the calling flow entry + * ({@linkcode auth} / a transport). Trust flows only from the provider, so the + * hook rides this internal variant rather than the public signature — direct + * callers configure it on the provider they hand to those entries. + */ +async function discoverAuthorizationServerMetadataInternal( + authorizationServerUrl: string | URL, + { + fetchFn = fetch, + protocolVersion = LATEST_PROTOCOL_VERSION, + skipIssuerValidation = false, + discoveryPolicy + }: NonNullable[1]> = {}, + validateDiscoveryURL?: ValidateDiscoveryUrlHook ): Promise { const headers = { 'MCP-Protocol-Version': protocolVersion, @@ -1804,9 +2353,34 @@ export async function discoverAuthorizationServerMetadata( // Get the list of URLs to try const urlsToTry = buildDiscoveryUrls(authorizationServerUrl); - // Try each URL in order + // URL-policy producer for the discovery requests below. The derived URLs share the + // authorization server's origin, so this primarily enforces the structural rules + // (https-or-loopback, no userinfo) for direct callers of this function; within + // auth() the authorization server URL has already passed the adoption checks. + const gate: DiscoveryFetchGate = { + purpose: 'as-metadata', + source: 'sdk-derived', + producer: { url: new URL(authorizationServerUrl), kind: 'authorization-server' }, + policy: discoveryPolicy, + validate: validateDiscoveryURL + }; + + // Try each URL in order. Each candidate is validated inside the fetch path + // before any request leaves the client; a rejected URL fails the whole + // discovery rather than falling through to the next candidate. for (const { url: endpointUrl, type } of urlsToTry) { - const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn); + let response: Response | undefined; + try { + response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn, gate); + } catch (error) { + if (!(error instanceof RedirectFilteredResponseError)) { + throw error; + } + // A runtime-filtered redirect means the metadata document is not + // retrievably served at this candidate — the same degradation as a + // 404: try the next one. + continue; + } if (!response) { /** @@ -1855,6 +2429,88 @@ export async function discoverAuthorizationServerMetadata( return undefined; } +/** + * Signature of {@linkcode OAuthClientProvider.validateDiscoveryURL}, as threaded + * from a flow entry to the URL checkpoints (already bound to the provider). + * + * @internal Not part of the public barrel. + */ +export type ValidateDiscoveryUrlHook = (ctx: DiscoveryUrlContext) => void | Promise; + +/** + * Runs a provider's `validateDiscoveryURL` hook against a context that has already + * passed the mechanical URL policy. A rejection always surfaces as + * {@linkcode DiscoveryUrlBlockedError}: a hook throw of any other type is wrapped + * (with the original throw as `cause`), so callers observe one failure type for + * every URL-policy or trust rejection. + */ +async function invokeValidateDiscoveryUrlHook(ctx: DiscoveryUrlContext, validate: ValidateDiscoveryUrlHook | undefined): Promise { + if (!validate) { + return; + } + try { + await validate(ctx); + } catch (error) { + if (error instanceof DiscoveryUrlBlockedError) { + throw error; + } + const blocked = new DiscoveryUrlBlockedError( + ctx, + `rejected by the provider's validateDiscoveryURL hook: ${error instanceof Error ? error.message : String(error)}` + ); + blocked.cause = error; + throw blocked; + } +} + +/** + * The RFC 9728 §7.6 trust members of an {@linkcode OAuthClientProvider} + * ({@linkcode OAuthClientProvider.trustedAuthorizationServers | trustedAuthorizationServers} + * and {@linkcode OAuthClientProvider.validateDiscoveryURL | validateDiscoveryURL}), + * resolved once at flow entry and threaded to the URL checkpoints. Trust flows only + * from the provider: standalone callers configure it by implementing these members on + * the provider they hand to {@linkcode auth} (or a transport), not through loose + * per-call options. + * + * @internal Not part of the public barrel. + */ +export interface AuthorizationServerTrust { + /** See {@linkcode OAuthClientProvider.trustedAuthorizationServers}. */ + trustedAuthorizationServers?: (string | URL)[]; + /** See {@linkcode OAuthClientProvider.validateDiscoveryURL}. */ + validateDiscoveryURL?(ctx: DiscoveryUrlContext): void | Promise; +} + +/** + * RFC 9728 §7.6 trust verification applied when an authorization server URL is + * adopted (fresh discovery or cached-state restore), after the mechanical URL policy + * ({@linkcode assertAllowedDiscoveryUrl}): the exhaustive + * `trustedAuthorizationServers` match when configured — full issuer identifiers, + * tolerating only a trailing-slash difference — then the `validateDiscoveryURL` + * hook when implemented. + */ +async function verifyAuthorizationServerTrust(ctx: DiscoveryUrlContext, trust: AuthorizationServerTrust): Promise { + // Both trust inputs are optional and default to undefined — when neither is + // configured no trust verification runs. + const trustedServers = trust.trustedAuthorizationServers; + if (trustedServers !== undefined) { + const trustedIssuers = trustedServers.map(entry => { + try { + return new URL(String(entry)).href; + } catch { + throw new Error(`Invalid trustedAuthorizationServers entry (must be an absolute URL): ${String(entry)}`); + } + }); + if (!trustedIssuers.some(entry => issuersMatch(entry, ctx.url.href))) { + throw new DiscoveryUrlBlockedError( + ctx, + 'the authorization server issuer does not match any trustedAuthorizationServers entry (RFC 9728 §7.6)' + ); + } + } + await invokeValidateDiscoveryUrlHook(ctx, trust.validateDiscoveryURL); +} + /** * Result of {@linkcode discoverOAuthServerInfo}. */ @@ -1892,6 +2548,12 @@ export interface OAuthServerInfo { * Use this when you need the authorization server metadata for operations outside the * {@linkcode auth} orchestrator, such as token refresh or token revocation. * + * RFC 9728 §7.6 trust verification of the adopted authorization server is configured + * on the {@linkcode OAuthClientProvider} (`trustedAuthorizationServers` / + * `validateDiscoveryURL`) and applies when this discovery runs inside a + * provider-driven flow ({@linkcode auth} or a transport); there is no per-call + * trust override. + * * @param serverUrl - The MCP resource server URL * @param opts - Optional configuration * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint @@ -1902,46 +2564,142 @@ export async function discoverOAuthServerInfo( serverUrl: string | URL, opts?: { resourceMetadataUrl?: URL; + /** + * Where `resourceMetadataUrl` came from — see + * {@linkcode ResourceMetadataUrlSource}. Defaults to `'caller'`, which + * fails closed on a URL-policy rejection. + */ + resourceMetadataUrlSource?: ResourceMetadataUrlSource; fetchFn?: FetchLike; /** * Forwarded to {@linkcode discoverAuthorizationServerMetadata} as * `skipIssuerValidation`. **Security-weakening** — see {@linkcode AuthOptions.skipIssuerMetadataValidation}. */ skipIssuerMetadataValidation?: boolean; + /** + * Overrides for the URL policy applied before each discovery request or + * adoption ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } ): Promise { + return discoverOAuthServerInfoInternal(serverUrl, opts); +} + +/** + * Whether a protected-resource-metadata discovery failure is a runtime-filtered + * redirect ({@linkcode RedirectFilteredResponseError}) on a caller-configured + * metadata URL. A caller-configured URL is a configuration statement, so its + * failure propagates out of the flow; the same failure on an SDK-derived + * well-known probe means the metadata is not retrievably served there and + * degrades exactly like a 404. (A challenge-relayed URL never reaches these + * catch sites with this error — its filtered redirect falls back to the + * well-known derivation inside `discoverMetadataWithFallback`.) + */ +function isFilteredCallerMetadataUrl( + error: unknown, + explicitUrl: string | URL | undefined, + source: ResourceMetadataUrlSource | undefined +): boolean { + return error instanceof RedirectFilteredResponseError && explicitUrl !== undefined && (source ?? 'caller') === 'caller'; +} + +/** + * {@linkcode discoverOAuthServerInfo} with the provider's RFC 9728 §7.6 trust + * members ({@linkcode AuthorizationServerTrust}) threaded by the calling flow + * entry ({@linkcode auth} / a transport). Trust flows only from the provider, + * so it rides this internal variant rather than the public signature — direct + * callers configure it on the provider they hand to those entries. + * + * @internal Not part of the public barrel; exported for the policy wiring + * tests, which drive the trust seam directly. + */ +export async function discoverOAuthServerInfoInternal( + serverUrl: string | URL, + opts: Parameters[1], + trust?: AuthorizationServerTrust +): Promise { + const serverUrlObject = new URL(serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( + resourceMetadata = await discoverOAuthProtectedResourceMetadataInternal( serverUrl, - { resourceMetadataUrl: opts?.resourceMetadataUrl }, - opts?.fetchFn + { + resourceMetadataUrl: opts?.resourceMetadataUrl, + resourceMetadataUrlSource: opts?.resourceMetadataUrlSource, + discoveryPolicy: opts?.discoveryPolicy + }, + opts?.fetchFn, + trust?.validateDiscoveryURL ); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; - } } catch (error) { // Network failures (DNS, connection refused) surface as TypeError from fetch. Those are // transient reachability problems, not "server doesn't support PRM" — propagate so the // caller sees the real error instead of silently falling back to a different auth server. - if (error instanceof TypeError) { + // A URL-policy rejection (e.g. a redirect hop into blocked address space) likewise fails + // closed rather than silently switching to the legacy authorization server, as does a + // runtime-filtered redirect on a caller-configured metadata URL (an SDK-derived probe's + // filtered redirect instead degrades to the fallback below, exactly like a 404). + if ( + error instanceof TypeError || + error instanceof DiscoveryUrlBlockedError || + isFilteredCallerMetadataUrl(error, opts?.resourceMetadataUrl, opts?.resourceMetadataUrlSource) + ) { throw error; } // RFC 9728 not supported -- fall back to treating the server URL as the authorization server } + if (resourceMetadata?.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + // RFC 9728 §7.6: the authorization server named by the protected-resource + // metadata must pass the URL policy — and the caller's trust verification, + // when configured — before it is adopted. Fail closed: a rejected entry + // neither falls back to the legacy serverUrl-derived authorization server + // (that would silently change the authorization server identity) nor falls + // through to another `authorization_servers` entry. + const candidate = resourceMetadata.authorization_servers[0]!; + const adoptionContext: DiscoveryUrlContext = { + purpose: 'authorization-server', + url: new URL(candidate), + producer: { url: serverUrlObject, kind: 'mcp-server' }, + source: 'protected-resource-metadata' + }; + assertAllowedDiscoveryUrl(adoptionContext, opts?.discoveryPolicy); + await verifyAuthorizationServerTrust(adoptionContext, trust ?? {}); + authorizationServerUrl = candidate; + } + // If we don't get a valid authorization server from protected resource metadata, // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server if (!authorizationServerUrl) { authorizationServerUrl = String(new URL('/', serverUrl)); + // The legacy fallback is an adoption like any other: the mechanical policy + // and the caller's RFC 9728 §7.6 trust verification both apply, so whether + // the server serves protected-resource metadata cannot change which + // authorization servers the caller has agreed to. A configured trust list + // must name the server's base URL for the fallback to be taken; hooks can + // tell this path apart by `source: 'sdk-derived'`. + const fallbackContext: DiscoveryUrlContext = { + purpose: 'authorization-server', + url: new URL(authorizationServerUrl), + producer: { url: serverUrlObject, kind: 'mcp-server' }, + source: 'sdk-derived' + }; + assertAllowedDiscoveryUrl(fallbackContext, opts?.discoveryPolicy); + await verifyAuthorizationServerTrust(fallbackContext, trust ?? {}); } - const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn: opts?.fetchFn, - skipIssuerValidation: opts?.skipIssuerMetadataValidation - }); + const authorizationServerMetadata = await discoverAuthorizationServerMetadataInternal( + authorizationServerUrl, + { + fetchFn: opts?.fetchFn, + skipIssuerValidation: opts?.skipIssuerMetadataValidation, + discoveryPolicy: opts?.discoveryPolicy + }, + trust?.validateDiscoveryURL + ); return { authorizationServerUrl, @@ -1955,21 +2713,34 @@ export async function discoverOAuthServerInfo( */ export async function startAuthorization( authorizationServerUrl: string | URL, - { - metadata, - clientInformation, - redirectUrl, - scope, - state, - resource - }: { + options: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; redirectUrl: string | URL; scope?: string; state?: string; resource?: URL; + /** + * Overrides for the URL policy applied to the authorization endpoint + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } +): Promise<{ authorizationUrl: URL; codeVerifier: string }> { + return startAuthorizationInternal(authorizationServerUrl, options); +} + +/** + * {@linkcode startAuthorization} with the provider's `validateDiscoveryURL` + * hook threaded by the calling flow entry ({@linkcode auth} / a transport). + * Trust flows only from the provider, so the hook rides this internal variant + * rather than the public signature — direct callers configure it on the + * provider they hand to those entries. + */ +async function startAuthorizationInternal( + authorizationServerUrl: string | URL, + { metadata, clientInformation, redirectUrl, scope, state, resource, discoveryPolicy }: Parameters[1], + validateDiscoveryURL?: ValidateDiscoveryUrlHook ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { let authorizationUrl: URL; if (metadata) { @@ -1989,6 +2760,19 @@ export async function startAuthorization( authorizationUrl = new URL('/authorize', authorizationServerUrl); } + // The user agent is redirected here carrying the authorization request, so the + // endpoint gets the same https-or-loopback and locality rules as every fetched + // endpoint, keyed on the authorization server that published it. Asserted only — + // this URL is never requested by the SDK. + const authorizationEndpointContext: DiscoveryUrlContext = { + purpose: 'authorization-endpoint', + url: authorizationUrl, + producer: { url: new URL(authorizationServerUrl), kind: 'authorization-server' }, + source: metadata ? 'authorization-server-metadata' : 'sdk-derived' + }; + assertAllowedDiscoveryUrl(authorizationEndpointContext, discoveryPolicy); + await invokeValidateDiscoveryUrlHook(authorizationEndpointContext, validateDiscoveryURL); + // Generate PKCE challenge const challenge = await pkceChallenge(); const codeVerifier = challenge.code_verifier; @@ -2058,7 +2842,8 @@ export async function executeTokenRequest( clientInformation, addClientAuthentication, resource, - fetchFn + fetchFn, + discoveryPolicy }: { metadata?: AuthorizationServerMetadata; tokenRequestParams: URLSearchParams; @@ -2066,8 +2851,24 @@ export async function executeTokenRequest( addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; resource?: URL; fetchFn?: FetchLike; - } + /** + * Overrides for the URL policy applied to the token endpoint + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; + }, + /** + * The provider's `validateDiscoveryURL` hook, threaded by the calling flow + * entry. Trust flows only from the provider. + * + * @internal + */ + validateDiscoveryURL?: ValidateDiscoveryUrlHook ): Promise { + // The scheme rule keeps its dedicated error type (SEP-2207); the full URL + // policy — including the locality rules keyed on the authorization server + // that published the endpoint — is applied inside the fetch path below, + // before the request is sent. const tokenUrl = assertSecureTokenEndpoint(metadata?.token_endpoint ?? new URL('/token', authorizationServerUrl)); const headers = new Headers({ @@ -2087,10 +2888,12 @@ export async function executeTokenRequest( applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams); } - const response = await (fetchFn ?? fetch)(tokenUrl, { - method: 'POST', - headers, - body: tokenRequestParams + const response = await fetchDiscoveryUrl(tokenUrl, { method: 'POST', headers, body: tokenRequestParams }, fetchFn ?? fetch, { + purpose: 'token-endpoint', + source: metadata ? 'authorization-server-metadata' : 'sdk-derived', + producer: { url: new URL(authorizationServerUrl), kind: 'authorization-server' }, + policy: discoveryPolicy, + validate: validateDiscoveryURL }); if (!response.ok) { @@ -2134,7 +2937,8 @@ export async function exchangeAuthorization( redirectUri, resource, addClientAuthentication, - fetchFn + fetchFn, + discoveryPolicy }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; @@ -2150,6 +2954,11 @@ export async function exchangeAuthorization( resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; + /** + * Overrides for the URL policy applied to the token endpoint + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } ): Promise { validateAuthorizationResponseIssuer({ @@ -2166,7 +2975,8 @@ export async function exchangeAuthorization( clientInformation, addClientAuthentication, resource, - fetchFn + fetchFn, + discoveryPolicy }); } @@ -2184,36 +2994,62 @@ export async function exchangeAuthorization( */ export async function refreshAuthorization( authorizationServerUrl: string | URL, - { - metadata, - clientInformation, - refreshToken, - resource, - addClientAuthentication, - fetchFn - }: { + options: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; + /** + * Overrides for the URL policy applied to the token endpoint + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } ): Promise { - const tokenRequestParams = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken - }); + return refreshAuthorizationInternal(authorizationServerUrl, options); +} - const tokens = await executeTokenRequest(authorizationServerUrl, { +/** + * {@linkcode refreshAuthorization} with the provider's `validateDiscoveryURL` + * hook threaded by the calling flow entry ({@linkcode auth} / a transport). + * Trust flows only from the provider, so the hook rides this internal variant + * rather than the public signature — direct callers configure it on the + * provider they hand to those entries. + */ +async function refreshAuthorizationInternal( + authorizationServerUrl: string | URL, + { metadata, - tokenRequestParams, clientInformation, - addClientAuthentication, + refreshToken, resource, - fetchFn + addClientAuthentication, + fetchFn, + discoveryPolicy + }: Parameters[1], + validateDiscoveryURL?: ValidateDiscoveryUrlHook +): Promise { + const tokenRequestParams = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken }); + const tokens = await executeTokenRequest( + authorizationServerUrl, + { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn, + discoveryPolicy + }, + validateDiscoveryURL + ); + // Preserve original refresh token if server didn't return a new one return { refresh_token: refreshToken, ...tokens }; } @@ -2254,7 +3090,8 @@ export async function fetchToken( authorizationCode, iss, scope, - fetchFn + fetchFn, + discoveryPolicy }: { metadata?: AuthorizationServerMetadata; resource?: URL; @@ -2269,8 +3106,20 @@ export async function fetchToken( /** Optional scope parameter from auth() options */ scope?: string; fetchFn?: FetchLike; + /** + * Overrides for the URL policy applied to the token endpoint + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + * Defaults to the provider's {@linkcode OAuthClientProvider.discoveryPolicy}. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } = {} ): Promise { + // Effective policy resolved once at this flow entry: per-call options take + // precedence over the provider's durable policy. The provider's trust hook is + // threaded to the token-endpoint checkpoint the same way. + discoveryPolicy ??= provider.discoveryPolicy; + const validateDiscoveryURL = provider.validateDiscoveryURL?.bind(provider); + if (authorizationCode !== undefined) { validateAuthorizationResponseIssuer({ iss, @@ -2302,14 +3151,19 @@ export async function fetchToken( const clientInformation = await provider.clientInformation({ issuer: metadata?.issuer ?? String(authorizationServerUrl) }); - return executeTokenRequest(authorizationServerUrl, { - metadata, - tokenRequestParams, - clientInformation: clientInformation ?? undefined, - addClientAuthentication: provider.addClientAuthentication, - resource, - fetchFn - }); + return executeTokenRequest( + authorizationServerUrl, + { + metadata, + tokenRequestParams, + clientInformation: clientInformation ?? undefined, + addClientAuthentication: provider.addClientAuthentication, + resource, + fetchFn, + discoveryPolicy + }, + validateDiscoveryURL + ); } /** @@ -2328,17 +3182,32 @@ export async function fetchToken( */ export async function registerClient( authorizationServerUrl: string | URL, - { - metadata, - clientMetadata, - scope, - fetchFn - }: { + options: { metadata?: AuthorizationServerMetadata; clientMetadata: OAuthClientMetadata; scope?: string; fetchFn?: FetchLike; + /** + * Overrides for the URL policy applied to the registration endpoint + * ({@linkcode assertAllowedDiscoveryUrl}). **Policy-weakening**, default off. + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } +): Promise { + return registerClientInternal(authorizationServerUrl, options); +} + +/** + * {@linkcode registerClient} with the provider's `validateDiscoveryURL` hook + * threaded by the calling flow entry ({@linkcode auth} / a transport). Trust + * flows only from the provider, so the hook rides this internal variant rather + * than the public signature — direct callers configure it on the provider they + * hand to those entries. + */ +async function registerClientInternal( + authorizationServerUrl: string | URL, + { metadata, clientMetadata, scope, fetchFn, discoveryPolicy }: Parameters[1], + validateDiscoveryURL?: ValidateDiscoveryUrlHook ): Promise { let registrationUrl: URL; @@ -2360,13 +3229,22 @@ export async function registerClient( ...(scope === undefined ? {} : { scope }) }; - const response = await (fetchFn ?? fetch)(registrationUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(submittedMetadata) - }); + // The endpoint is validated inside the fetch path before the registration + // request is sent — the same https-or-loopback and locality rules as the + // token endpoint (SEP-2207), keyed on the authorization server that + // published the endpoint. + const response = await fetchDiscoveryUrl( + registrationUrl, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(submittedMetadata) }, + fetchFn ?? fetch, + { + purpose: 'registration-endpoint', + source: metadata ? 'authorization-server-metadata' : 'sdk-derived', + producer: { url: new URL(authorizationServerUrl), kind: 'authorization-server' }, + policy: discoveryPolicy, + validate: validateDiscoveryURL + } + ); if (!response.ok) { throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata }); diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index e8925b2f86..fb770a0227 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -6,7 +6,7 @@ * the failure mode without string-matching messages. */ -import type { OAuthClientMetadata } from '@modelcontextprotocol/core-internal'; +import type { DiscoveryUrlContext, DiscoveryUrlPurpose, OAuthClientMetadata } from '@modelcontextprotocol/core-internal'; import { brandedHasInstance, stampErrorBrands } from '@modelcontextprotocol/core-internal'; /** @@ -154,6 +154,53 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError { } } +/** + * Thrown by the discovery fetch path (`fetchDiscoveryUrl`) when a response is a + * redirect the runtime filtered — Fetch `Response.type === 'opaqueredirect'` + * (status 0, no readable `Location` header), the shape browser runtimes resolve + * a `redirect: 'manual'` request with when the response status is a redirect. + * The redirect target cannot be observed, so it can neither be checked nor + * followed; see `throwIfRedirectFiltered` in `auth.ts` for the mechanism and + * the per-purpose handling the discovery flows apply. + * + * The flows that can proceed without the redirected document handle this error + * internally (the SDK-derived well-known probes degrade exactly like a 404, and + * a challenge-relayed metadata URL falls back to the well-known derivation), so + * it surfaces to callers only where the request's outcome is required — a + * caller-configured metadata URL, or a token/registration POST (reached through + * `auth()`, the transports' 401/`finishAuth` flows, `exchangeAuthorization()`, + * `refreshAuthorization()`, `fetchToken()`, `registerClient()`, and the + * Cross-App Access token exchanges). + */ +export class RedirectFilteredResponseError extends OAuthClientFlowError { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.RedirectFilteredResponseError' }); + } + + constructor( + /** + * The URL whose response was the filtered redirect. The redirect target + * itself is not observable and is never part of this error. + */ + public readonly url: URL, + /** + * What the request was for. `'redirect-hop'` when the filtered response + * answered a followed redirect hop — {@linkcode RedirectFilteredResponseError.redirectHop | redirectHop} + * then carries the hop details, matching the context shape of other + * hop-stage rejections. + */ + public readonly purpose: DiscoveryUrlPurpose, + detail: string, + /** Set when the filtered response answered a followed redirect hop. */ + public readonly redirectHop?: NonNullable + ) { + super( + `The response to ${url.href} (purpose '${purpose}') is a redirect this runtime filters ` + + `(Fetch opaqueredirect: status 0, no readable Location header), so the redirect target cannot be observed: ${detail}` + ); + } +} + /** * Thrown by the HTTP client transport when the server responds with * `403 Forbidden` and `WWW-Authenticate: Bearer error="insufficient_scope"`, diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index a7703dbf15..20e02e6037 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -8,11 +8,11 @@ * @module */ -import type { FetchLike } from '@modelcontextprotocol/core-internal'; +import type { DiscoveryUrlPolicyOptions, DiscoveryUrlProducer, DiscoveryUrlSource, FetchLike } from '@modelcontextprotocol/core-internal'; import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; import type { ClientAuthMethod } from './auth'; -import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth'; +import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata, fetchDiscoveryUrl } from './auth'; /** * Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange. @@ -62,6 +62,14 @@ export interface RequestJwtAuthGrantOptions { * Custom fetch implementation. Defaults to global fetch. */ fetchFn?: FetchLike; + + /** + * Overrides for the URL policy applied to the token endpoint before the + * request is sent. **Policy-weakening**, default off. These standalone + * helpers run without an `OAuthClientProvider`, so this per-call + * bag is the only policy input (there is no provider hook on this path). + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; } /** @@ -122,9 +130,28 @@ export interface JwtAuthGrantResult { * ``` */ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise { - const { tokenEndpoint, audience, resource, idToken, clientId, clientSecret, scope, fetchFn = fetch } = options; + const tokenUrl = assertSecureTokenEndpoint(options.tokenEndpoint); + // The caller names the IdP's token endpoint directly, so the endpoint is its + // own locality anchor for the URL policy; the structural rules + // (https-or-loopback, no userinfo) still apply before the request. + return executeJwtAuthGrantRequest(tokenUrl, options, { + producer: { url: new URL(tokenUrl), kind: 'authorization-server' }, + source: 'caller' + }); +} - const tokenUrl = assertSecureTokenEndpoint(tokenEndpoint); +/** + * Shared executor for the RFC 8693 token exchange: the request is issued through + * the discovery fetch path, so the token endpoint is validated before the request + * — keyed on the authorization server that published it — and a redirected POST is + * never re-sent to the `Location` target (token responses are terminal, RFC 6749 §5). + */ +async function executeJwtAuthGrantRequest( + tokenUrl: URL, + options: Omit, + gate: { producer: DiscoveryUrlProducer; source: DiscoveryUrlSource } +): Promise { + const { audience, resource, idToken, clientId, clientSecret, scope, discoveryPolicy, fetchFn = fetch } = options; // Prepare token exchange request per RFC 8693 const params = new URLSearchParams({ @@ -147,13 +174,23 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO params.set('scope', scope); } - const response = await fetchFn(tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + const response = await fetchDiscoveryUrl( + tokenUrl, + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() }, - body: params.toString() - }); + fetchFn, + { + purpose: 'token-endpoint', + source: gate.source, + producer: gate.producer, + policy: discoveryPolicy + } + ); if (!response.ok) { const errorBody = await response.json().catch(() => ({})); @@ -206,18 +243,23 @@ export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequest const { idpUrl, fetchFn = fetch, ...restOptions } = options; // Discover IdP's authorization server metadata - const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn }); + const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn, discoveryPolicy: options.discoveryPolicy }); if (!metadata?.token_endpoint) { throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`); } - // Perform token exchange - return requestJwtAuthorizationGrant({ - ...restOptions, - tokenEndpoint: metadata.token_endpoint, - fetchFn - }); + // Perform token exchange. The endpoint was published by the IdP's metadata, so + // the IdP issuer anchors the locality check on the token request. + const tokenUrl = assertSecureTokenEndpoint(metadata.token_endpoint); + return executeJwtAuthGrantRequest( + tokenUrl, + { ...restOptions, fetchFn }, + { + producer: { url: new URL(idpUrl), kind: 'authorization-server' }, + source: 'authorization-server-metadata' + } + ); } /** @@ -259,8 +301,23 @@ export async function exchangeJwtAuthGrant(options: { */ authMethod?: ClientAuthMethod; fetchFn?: FetchLike; + /** + * Overrides for the URL policy applied to the token endpoint before the + * request is sent. **Policy-weakening**, default off. This standalone helper + * runs without an `OAuthClientProvider`, so this per-call bag is + * the only policy input (there is no provider hook on this path). + */ + discoveryPolicy?: DiscoveryUrlPolicyOptions; }): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> { - const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options; + const { + tokenEndpoint, + jwtAuthGrant, + clientId, + clientSecret, + authMethod = 'client_secret_basic', + discoveryPolicy, + fetchFn = fetch + } = options; const tokenUrl = assertSecureTokenEndpoint(tokenEndpoint); @@ -276,11 +333,23 @@ export async function exchangeJwtAuthGrant(options: { applyClientAuthentication(authMethod, { client_id: clientId, client_secret: clientSecret }, headers, params); - const response = await fetchFn(tokenUrl, { - method: 'POST', - headers, - body: params.toString() - }); + const response = await fetchDiscoveryUrl( + tokenUrl, + { + method: 'POST', + headers, + body: params.toString() + }, + fetchFn, + { + purpose: 'token-endpoint', + // The caller names the authorization server's token endpoint directly, + // so the endpoint is its own locality anchor for the URL policy. + source: 'caller', + producer: { url: new URL(tokenUrl), kind: 'authorization-server' }, + policy: discoveryPolicy + } + ); if (!response.ok) { const errorBody = await response.json().catch(() => ({})); diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index c86db72d2c..125f9acfaa 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,7 +1,9 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal'; +import { DiscoveryUrlBlockedError } from '@modelcontextprotocol/core-internal'; import type { OAuthClientProvider } from './auth'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth'; +import { RedirectFilteredResponseError } from './authErrors'; /** * Middleware function that wraps and enhances fetch functionality. @@ -64,6 +66,12 @@ export const withOAuth = const result = await auth(provider, { serverUrl, resourceMetadataUrl, + // Relayed from the challenge: a URL the mechanical policy rejects — or + // whose response is a redirect the runtime filters — is set aside with + // a warning while discovery falls back to the well-known derivation; a + // rejection from the provider's validateDiscoveryURL hook still fails + // the flow. See {@linkcode ResourceMetadataUrlSource}. + resourceMetadataUrlSource: 'www-authenticate', scope, fetchFn: next }); @@ -82,6 +90,12 @@ export const withOAuth = if (error instanceof UnauthorizedError) { throw error; } + // The typed URL-policy and redirect-handling failures carry structured + // context callers branch on — propagate them instead of flattening + // them into an UnauthorizedError message string. + if (error instanceof DiscoveryUrlBlockedError || error instanceof RedirectFilteredResponseError) { + throw error; + } throw new UnauthorizedError(`Failed to re-authenticate: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 1c81928f10..75d3abf341 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -103,9 +103,30 @@ export type SSEClientTransportOptions = { /** * Customizes recurring `POST` requests to the server. + * + * Request headers configured here are **not** applied to authorization requests + * (protected-resource metadata, authorization-server metadata, token, and client + * registration requests) — those may target a different origin than the MCP server, + * so connection-level headers do not carry over. Use + * {@linkcode SSEClientTransportOptions.oauthRequestInit | oauthRequestInit} + * to configure headers for those requests explicitly. */ requestInit?: RequestInit; + /** + * Customizes the OAuth requests issued by the transport's authorization flow + * (protected-resource metadata, authorization-server metadata, token, and client + * registration requests). Headers from + * {@linkcode SSEClientTransportOptions.requestInit | requestInit} are never applied + * to these requests; set this option when they need extra configuration + * (e.g. gateway headers on well-known endpoints). + * + * Headers configured here do not follow cross-origin redirects: when a + * discovery request is redirected to a different origin, the redirected + * request is issued without them. + */ + oauthRequestInit?: RequestInit; + /** * Custom fetch implementation used for all network requests. */ @@ -130,7 +151,13 @@ export class SSEClientTransport implements Transport { private _oauthProvider?: OAuthClientProvider; private _skipIssuerMetadataValidation?: boolean; private _fetch?: FetchLike; - private _fetchWithInit: FetchLike; + /** + * Fetch used for authorization requests. Deliberately does NOT merge + * `requestInit` — connection-level headers must not reach authorization + * endpoints on other origins. Only `oauthRequestInit` is merged, as the + * explicit opt-in. + */ + private _authFetch: FetchLike; private _protocolVersion?: string; onclose?: () => void; @@ -153,7 +180,7 @@ export class SSEClientTransport implements Transport { this._authProvider = opts?.authProvider; } this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit); } private _last401Response?: Response; @@ -209,7 +236,7 @@ export class SSEClientTransport implements Transport { const response = this._last401Response; this._last401Response = undefined; this._eventSource?.close(); - this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( + this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._authFetch }).then( // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. () => this._startOrAuth().then(resolve, reject), // onUnauthorized failed → not yet reported. @@ -309,7 +336,7 @@ export class SSEClientTransport implements Transport { iss, this._oauthProvider, this._url, - { fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl } + { fetchFn: this._authFetch, resourceMetadataUrl: this._resourceMetadataUrl, resourceMetadataUrlSource: 'www-authenticate' } ); const result = await auth(this._oauthProvider, { @@ -317,8 +344,12 @@ export class SSEClientTransport implements Transport { authorizationCode, iss: issParam, resourceMetadataUrl: this._resourceMetadataUrl, + // `_resourceMetadataUrl` is only ever populated from a WWW-Authenticate + // challenge, so it is labeled as challenge-relayed: a URL-policy + // rejection falls back to well-known discovery instead of failing. + resourceMetadataUrlSource: 'www-authenticate', scope: this._scope, - fetchFn: this._fetchWithInit, + fetchFn: this._authFetch, skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); if (result !== 'AUTHORIZED') { @@ -365,7 +396,7 @@ export class SSEClientTransport implements Transport { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._authFetch }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0d8eff917b..a3474e3122 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -175,10 +175,32 @@ export type StreamableHTTPClientTransportOptions = { skipIssuerMetadataValidation?: boolean; /** - * Customizes HTTP requests to the server. + * Customizes HTTP requests to the MCP server (the data-plane `POST`/`GET`/`DELETE` + * requests to the MCP endpoint). + * + * Request headers configured here are **not** applied to authorization requests + * (protected-resource metadata, authorization-server metadata, token, and client + * registration requests) — those may target a different origin than the MCP server, + * so connection-level headers do not carry over. Use + * {@linkcode StreamableHTTPClientTransportOptions.oauthRequestInit | oauthRequestInit} + * to configure headers for those requests explicitly. */ requestInit?: RequestInit; + /** + * Customizes the OAuth requests issued by the transport's authorization flow + * (protected-resource metadata, authorization-server metadata, token, and client + * registration requests). Headers from + * {@linkcode StreamableHTTPClientTransportOptions.requestInit | requestInit} are + * never applied to these requests; set this option when they need extra + * configuration (e.g. gateway headers on well-known endpoints). + * + * Headers configured here do not follow cross-origin redirects: when a + * discovery request is redirected to a different origin, the redirected + * request is issued without them. + */ + oauthRequestInit?: RequestInit; + /** * Custom fetch implementation used for all network requests. */ @@ -313,7 +335,13 @@ export class StreamableHTTPClientTransport implements Transport { private _oauthProvider?: OAuthClientProvider; private _skipIssuerMetadataValidation?: boolean; private _fetch?: FetchLike; - private _fetchWithInit: FetchLike; + /** + * Fetch used for authorization requests. Deliberately does NOT merge + * `requestInit` — connection-level headers must not reach authorization + * endpoints on other origins. Only `oauthRequestInit` is merged, as the + * explicit opt-in. + */ + private _authFetch: FetchLike; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; @@ -350,7 +378,7 @@ export class StreamableHTTPClientTransport implements Transport { this._authProvider = opts?.authProvider; } this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._authFetch = createFetchWithInit(opts?.fetch, opts?.oauthRequestInit); this._sessionId = opts?.sessionId; this._protocolVersion = opts?.protocolVersion; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; @@ -412,9 +440,13 @@ export class StreamableHTTPClientTransport implements Transport { return auth(this._oauthProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, + // `_resourceMetadataUrl` is only ever populated from a WWW-Authenticate + // challenge, so it is labeled as challenge-relayed: a URL-policy + // rejection falls back to well-known discovery instead of failing. + resourceMetadataUrlSource: 'www-authenticate', scope: unionScope, forceReauthorization, - fetchFn: this._fetchWithInit, + fetchFn: this._authFetch, skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); } @@ -545,7 +577,7 @@ export class StreamableHTTPClientTransport implements Transport { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._authFetch }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice @@ -863,7 +895,7 @@ export class StreamableHTTPClientTransport implements Transport { iss, this._oauthProvider, this._url, - { fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl } + { fetchFn: this._authFetch, resourceMetadataUrl: this._resourceMetadataUrl, resourceMetadataUrlSource: 'www-authenticate' } ); const result = await auth(this._oauthProvider, { @@ -871,8 +903,11 @@ export class StreamableHTTPClientTransport implements Transport { authorizationCode, iss: issParam, resourceMetadataUrl: this._resourceMetadataUrl, + // `_resourceMetadataUrl` is only ever populated from a WWW-Authenticate + // challenge — see `_stepUpAuthorize`. + resourceMetadataUrlSource: 'www-authenticate', scope: this._scope, - fetchFn: this._fetchWithInit, + fetchFn: this._authFetch, skipIssuerMetadataValidation: this._skipIssuerMetadataValidation }); if (result !== 'AUTHORIZED') { @@ -994,7 +1029,7 @@ export class StreamableHTTPClientTransport implements Transport { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._authFetch }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c9088fc499..2e2be36c97 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -15,7 +15,8 @@ export type { OAuthClientInformationContext, OAuthClientProvider, OAuthDiscoveryState, - OAuthServerInfo + OAuthServerInfo, + ResourceMetadataUrlSource } from './client/auth'; export { assertSecureTokenEndpoint, @@ -50,6 +51,7 @@ export { InsufficientScopeError, IssuerMismatchError, OAuthClientFlowError, + RedirectFilteredResponseError, RegistrationRejectedError } from './client/authErrors'; export type { @@ -100,6 +102,21 @@ export { StreamableHTTPClientTransport } from './client/streamableHttp'; // runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) export { fromJsonSchema } from './fromJsonSchema'; +// OAuth discovery URL policy (RFC 9728 §3 / §7.6, RFC 8414 §2): the mechanical +// validator the client OAuth flow runs on every network-derived URL, its +// failure type, and the context/option types the +// OAuthClientProvider.validateDiscoveryURL hook is written against. Client-only +// vocabulary, so it is exported here rather than from the shared core-internal +// public barrel (which the server package re-exports). +export type { + DiscoveryUrlContext, + DiscoveryUrlPolicyOptions, + DiscoveryUrlProducer, + DiscoveryUrlPurpose, + DiscoveryUrlSource +} from '@modelcontextprotocol/core-internal'; +export { assertAllowedDiscoveryUrl, DiscoveryUrlBlockedError } from '@modelcontextprotocol/core-internal'; + // Multi-round-trip requests (protocol revision 2026-07-28): the client-side // auto-fulfilment knobs (ClientOptions.inputRequired) and the manual-mode // schema wrapper for callers that opt out of auto-fulfilment per call. diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..48811226f4 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -6,7 +6,7 @@ import type { StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; -import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; +import { DiscoveryUrlBlockedError, LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; import type { Mock } from 'vitest'; import { expect, vi } from 'vitest'; @@ -543,7 +543,7 @@ describe('OAuth Authorization', () => { await expect( discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', { - resourceMetadataUrl: 'https://custom.example.com/metadata' + resourceMetadataUrl: 'https://resource.example.com/metadata' }) ).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.'); @@ -551,7 +551,7 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided const [url] = calls[0]!; - expect(url.toString()).toBe('https://custom.example.com/metadata'); + expect(url.toString()).toBe('https://resource.example.com/metadata'); }); it('supports overriding the fetch function used for requests', async () => { @@ -608,6 +608,26 @@ describe('OAuth Authorization', () => { }); }); + it('applies the discovery URL policy and honors a per-call discoveryPolicy override', async () => { + // The URL policy runs before any request: a non-loopback http issuer + // fails closed by default... + await expect(discoverOAuthMetadata('http://as.example.com')).rejects.toThrow(DiscoveryUrlBlockedError); + expect(mockFetch).not.toHaveBeenCalled(); + + // ...and the per-call override permits it, like the sibling + // discoverAuthorizationServerMetadata. + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validMetadata, issuer: 'http://as.example.com' }) + }); + const metadata = await discoverOAuthMetadata('http://as.example.com', { + discoveryPolicy: { allowHttpDiscovery: true } + }); + expect(metadata?.issuer).toBe('http://as.example.com'); + expect(mockFetch.mock.calls[0]![0].toString()).toBe('http://as.example.com/.well-known/oauth-authorization-server'); + }); + it('returns metadata when discovery succeeds with path', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1434,7 +1454,10 @@ describe('OAuth Authorization', () => { }); const result = await discoverOAuthServerInfo('https://resource.example.com', { - resourceMetadataUrl: overrideUrl + resourceMetadataUrl: overrideUrl, + // The override names a different origin than the server (RFC 9728 §3), + // so honoring it requires the explicit policy opt-out. + discoveryPolicy: { allowCrossOriginResourceMetadata: true } }); expect(result.resourceMetadata).toEqual(validResourceMetadata); @@ -1646,7 +1669,7 @@ describe('OAuth Authorization', () => { }); it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { - const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const cachedPrmUrl = 'https://resource.example.com/custom/.well-known/oauth-protected-resource'; const provider = createMockProvider({ // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata // (simulates browser redirect where PRM URL was saved but metadata wasn't) diff --git a/packages/client/test/client/authDiscoveryPolicy.test.ts b/packages/client/test/client/authDiscoveryPolicy.test.ts new file mode 100644 index 0000000000..1de97020e6 --- /dev/null +++ b/packages/client/test/client/authDiscoveryPolicy.test.ts @@ -0,0 +1,1405 @@ +/** + * Wiring tests for the discovery URL policy in the client OAuth flow: + * RFC 9728 §3 (protected-resource metadata location), RFC 9728 §7.6 + * (authorization server trust verification), RFC 8414 §2 (issuer syntax), and + * fail-closed pre-request validation of every discovery-derived URL, including + * redirect targets and endpoints resolved from authorization server metadata. + * + * Each rejection case asserts that no request is ever issued to the rejected + * URL — validation happens before network I/O, not after. + */ +import type { AuthorizationServerMetadata, DiscoveryUrlContext } from '@modelcontextprotocol/core-internal'; +import { createFetchWithInit, DiscoveryUrlBlockedError, normalizeHeaders, OMIT_BASE_HEADERS } from '@modelcontextprotocol/core-internal'; +import type { Mock } from 'vitest'; +import { expect, vi } from 'vitest'; + +import type { OAuthClientProvider } from '../../src/client/auth'; +import { + auth, + discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, + discoverOAuthServerInfoInternal, + exchangeAuthorization, + refreshAuthorization, + registerClient, + RegistrationRejectedError, + startAuthorization, + UnauthorizedError +} from '../../src/client/auth'; +import { RedirectFilteredResponseError } from '../../src/client/authErrors'; +import { withOAuth } from '../../src/client/middleware'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; + +// Mock pkce-challenge (startAuthorization generates a challenge before returning) +vi.mock('pkce-challenge', () => ({ + default: () => ({ + code_verifier: 'test_verifier', + code_challenge: 'test_challenge' + }) +})); + +const SERVER_URL = 'https://mcp.example.com/mcp'; + +const PRM_WELL_KNOWN = 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp'; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => body + } as unknown as Response; +} + +function redirectResponse(status: number, location?: string): Response { + return { + ok: false, + status, + headers: new Headers(location === undefined ? {} : { location }), + text: async () => '' + } as unknown as Response; +} + +/** + * The shape browser runtimes resolve a `redirect: 'manual'` fetch with when the + * response status is a redirect: an opaque redirect — status 0, no readable + * headers (Fetch "opaqueredirect" filtered response). + */ +function opaqueRedirectResponse(): Response { + return { + ok: false, + status: 0, + type: 'opaqueredirect', + headers: new Headers(), + text: async () => '' + } as unknown as Response; +} + +function notFoundResponse(): Response { + return { + ok: false, + status: 404, + headers: new Headers(), + text: async () => '' + } as unknown as Response; +} + +function resourceMetadataFor(serverUrl: string, authorizationServer: string): Record { + return { + resource: serverUrl, + authorization_servers: [authorizationServer] + }; +} + +function authServerMetadataFor(issuer: string): AuthorizationServerMetadata { + const base = issuer.replace(/\/$/, ''); + return { + issuer, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/token`, + registration_endpoint: `${base}/register`, + response_types_supported: ['code'] + }; +} + +/** A recording fetch mock routing by exact URL prefix; unrouted URLs reject loudly. */ +function routedFetch(routes: Record Response>): Mock { + return vi.fn(async (url: string | URL) => { + const urlString = url.toString(); + for (const [prefix, respond] of Object.entries(routes)) { + if (urlString === prefix || urlString.startsWith(prefix)) { + return respond(); + } + } + throw new Error(`Unrouted fetch: ${urlString}`); + }); +} + +/** + * Fetch stub for the challenge-relay tests: the MCP endpoint answers 401 with a + * `WWW-Authenticate` challenge naming `resourceMetadataUrl`, conformant metadata + * is served at the SDK's own well-known derivation, and auth.example.com serves + * its authorization server metadata. + */ +function challengeRelayFetch(resourceMetadataUrl: string): Mock { + return routedFetch({ + [SERVER_URL]: () => + ({ + ok: false, + status: 401, + headers: new Headers({ 'www-authenticate': `Bearer resource_metadata="${resourceMetadataUrl}"` }), + text: async () => '' + }) as unknown as Response, + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/': () => jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); +} + +function requestedUrls(fetchMock: Mock): string[] { + return fetchMock.mock.calls.map(call => String(call[0])); +} + +function requestedHosts(fetchMock: Mock): string[] { + return requestedUrls(fetchMock).map(url => new URL(url).hostname); +} + +function createProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + ...overrides + }; +} + +describe('discovery URL policy wiring in the client OAuth flow', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + describe('protected-resource metadata URL (RFC 9728 §3)', () => { + it('never requests a non-conformant challenge-relayed metadata URL; falls back to the same-origin well-known derivation with a warning', async () => { + // Shape from the WWW-Authenticate-relayed metadata URL reports: the URL + // names a link-local literal, and previously it was requested verbatim. + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + SERVER_URL, + { resourceMetadataUrl: 'http://169.254.169.254/latest/meta-data/', resourceMetadataUrlSource: 'www-authenticate' }, + fetchFn + ); + + expect(metadata.resource).toBe(SERVER_URL); + const urls = requestedUrls(fetchFn); + expect(urls).toEqual([PRM_WELL_KNOWN]); + expect(urls.map(url => new URL(url).hostname)).not.toContain('169.254.169.254'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('well-known')); + }); + + it('never requests a cross-origin https challenge-relayed metadata URL either (origin rule, not just scheme)', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + await discoverOAuthProtectedResourceMetadata( + SERVER_URL, + { + resourceMetadataUrl: 'https://other.example.com/.well-known/oauth-protected-resource', + resourceMetadataUrlSource: 'www-authenticate' + }, + fetchFn + ); + + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('a caller-configured metadata URL that violates the policy fails closed: no fallback, no request', async () => { + // Without a provenance label the URL is a configuration statement + // (`'caller'`, the default): rejecting it must not silently discover at + // a different location. + const fetchFn = vi.fn(); + + await expect( + discoverOAuthProtectedResourceMetadata( + SERVER_URL, + { resourceMetadataUrl: 'https://other.example.com/.well-known/oauth-protected-resource' }, + fetchFn + ) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('honors a cross-origin resource_metadata URL only with allowCrossOriginResourceMetadata', async () => { + const crossOriginUrl = 'https://gateway.example.com/prm'; + const fetchFn = routedFetch({ + [crossOriginUrl]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + SERVER_URL, + { resourceMetadataUrl: crossOriginUrl, discoveryPolicy: { allowCrossOriginResourceMetadata: true } }, + fetchFn + ); + + expect(metadata.resource).toBe(SERVER_URL); + expect(requestedUrls(fetchFn)).toEqual([crossOriginUrl]); + }); + + it('fails closed before any request when the derived well-known URL itself violates the policy', async () => { + // A non-loopback http server URL derives a non-loopback http discovery URL. + const fetchFn = vi.fn(); + + await expect(discoverOAuthProtectedResourceMetadata('http://mcp.example.com/mcp', undefined, fetchFn)).rejects.toThrow( + DiscoveryUrlBlockedError + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + }); + + describe('authorization server adoption (RFC 9728 §7.6)', () => { + it('rejects a loopback-literal authorization server published by a remote server, before any request to it', async () => { + // Shape shared by the authorization_servers[0] reports: remote server, + // protected-resource metadata naming a loopback listener. + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'http://127.0.0.1:9292')) + }); + + await expect(discoverOAuthServerInfo(SERVER_URL, { fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + + const urls = requestedUrls(fetchFn); + expect(urls).toEqual([PRM_WELL_KNOWN]); + expect(urls.map(url => new URL(url).hostname)).not.toContain('127.0.0.1'); + }); + + it('rejects a link-local http authorization server (structural rule) with zero requests to it', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'http://169.254.169.254')) + }); + + await expect(discoverOAuthServerInfo(SERVER_URL, { fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('rejects a private-range https literal from a remote server (locality rule)', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://10.0.0.8')) + }); + + await expect(discoverOAuthServerInfo(SERVER_URL, { fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('does not fall back to the legacy server-derived authorization server when the published entry is rejected', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'http://127.0.0.1:9292')) + }); + + await expect(discoverOAuthServerInfo(SERVER_URL, { fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + // Fail closed: no request to https://mcp.example.com/.well-known/oauth-authorization-server either. + expect(requestedUrls(fetchFn).some(url => url.includes('oauth-authorization-server'))).toBe(false); + }); + + it('keeps a loopback server with a loopback authorization server working (local development)', async () => { + const localServer = 'http://localhost:3111/mcp'; + const fetchFn = routedFetch({ + 'http://localhost:3111/.well-known/oauth-protected-resource/mcp': () => + jsonResponse(resourceMetadataFor(localServer, 'http://localhost:9000')), + 'http://localhost:9000/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('http://localhost:9000')) + }); + + const info = await discoverOAuthServerInfo(localServer, { fetchFn }); + expect(info.authorizationServerUrl).toBe('http://localhost:9000'); + expect(info.authorizationServerMetadata?.issuer).toBe('http://localhost:9000'); + }); + + it('keeps a public https authorization server on a different origin working (cross-origin AS is conformant)', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + const info = await discoverOAuthServerInfo(SERVER_URL, { fetchFn }); + expect(info.authorizationServerUrl).toBe('https://auth.example.com'); + expect(info.authorizationServerMetadata?.issuer).toBe('https://auth.example.com'); + }); + + it('keeps an authorization server on a private https DNS name working (no name resolution in the policy)', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://idp.corp.internal')), + 'https://idp.corp.internal/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://idp.corp.internal')) + }); + + const info = await discoverOAuthServerInfo(SERVER_URL, { fetchFn }); + expect(info.authorizationServerUrl).toBe('https://idp.corp.internal'); + }); + + it('allowPrivateAddressTargets permits a private-literal authorization server explicitly', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://10.0.0.8')), + 'https://10.0.0.8/.well-known/oauth-authorization-server': () => jsonResponse(authServerMetadataFor('https://10.0.0.8')) + }); + + const info = await discoverOAuthServerInfo(SERVER_URL, { + fetchFn, + discoveryPolicy: { allowPrivateAddressTargets: true } + }); + expect(info.authorizationServerUrl).toBe('https://10.0.0.8'); + }); + }); + + // These drive discoverOAuthServerInfoInternal, whose trust parameter carries + // the shape auth() resolves from the provider's trustedAuthorizationServers / + // validateDiscoveryURL members. There is no trust input on the public + // signature; trust flows only from the provider (RFC 9728 §7.6). + describe('trustedAuthorizationServers and validateDiscoveryURL (RFC 9728 §7.6)', () => { + it('trustedAuthorizationServers is exhaustive: a non-matching issuer is rejected before its metadata is requested', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + await expect( + discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + trustedAuthorizationServers: ['https://trusted.example.com'] + } + ) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('trustedAuthorizationServers accepts a matching issuer identifier (string or URL entries)', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + const info = await discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + trustedAuthorizationServers: [new URL('https://auth.example.com'), 'https://other.example.com'] + } + ); + expect(info.authorizationServerUrl).toBe('https://auth.example.com'); + }); + + it('entries are full issuer identifiers with trailing-slash tolerance only, not origins', async () => { + // Path-distinguished issuers on one host (RFC 8414 §2 permits a path + // component): listing one tenant does not trust another on the same host. + const tenantMetadata = resourceMetadataFor(SERVER_URL, 'https://auth.example.com/tenant1'); + const rejectedFetch = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(tenantMetadata) + }); + await expect( + discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn: rejectedFetch }, + { + trustedAuthorizationServers: ['https://auth.example.com/tenant2'] + } + ) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(rejectedFetch)).toEqual([PRM_WELL_KNOWN]); + }); + + it('an origin-only entry does not match a path-distinguished issuer', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com/tenant1')) + }); + + await expect( + discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + trustedAuthorizationServers: ['https://auth.example.com'] + } + ) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('tolerates a trailing-slash difference between the entry and the issuer', async () => { + const issuer = 'https://auth.example.com/tenant1'; + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, issuer)), + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1': () => jsonResponse(authServerMetadataFor(issuer)) + }); + + const info = await discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + trustedAuthorizationServers: ['https://auth.example.com/tenant1/'] + } + ); + expect(info.authorizationServerUrl).toBe(issuer); + }); + + it('the trust list applies to the legacy server-derived fallback: a server without protected-resource metadata is rejected unless its base URL is listed', async () => { + // Serving 404 on the well-known endpoint routes discovery into the legacy + // fallback (serverUrl acts as the authorization server) — that adoption + // must pass the same trust verification as one named by the metadata. + const fetchFn = routedFetch({ + 'https://mcp.example.com/.well-known/oauth-protected-resource': () => notFoundResponse() + }); + + await expect( + discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + trustedAuthorizationServers: ['https://trusted.example.com'] + } + ) + ).rejects.toThrow(DiscoveryUrlBlockedError); + // Rejected before any authorization-server metadata request. + expect(requestedUrls(fetchFn).some(url => url.includes('oauth-authorization-server'))).toBe(false); + }); + + it('the legacy fallback is accepted when the server base URL is on the trust list', async () => { + const fetchFn = routedFetch({ + 'https://mcp.example.com/.well-known/oauth-protected-resource': () => notFoundResponse(), + 'https://mcp.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://mcp.example.com')) + }); + + const info = await discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + trustedAuthorizationServers: ['https://mcp.example.com'] + } + ); + expect(info.authorizationServerUrl).toBe('https://mcp.example.com/'); + }); + + it("validateDiscoveryURL runs on the legacy fallback adoption with source 'sdk-derived' (and on each request checkpoint)", async () => { + const seenContexts: DiscoveryUrlContext[] = []; + const fetchFn = routedFetch({ + 'https://mcp.example.com/.well-known/oauth-protected-resource': () => notFoundResponse(), + 'https://mcp.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://mcp.example.com')) + }); + + await discoverOAuthServerInfoInternal( + SERVER_URL, + { fetchFn }, + { + validateDiscoveryURL: ctx => { + seenContexts.push(ctx); + } + } + ); + + const adoptions = seenContexts.filter(ctx => ctx.purpose === 'authorization-server'); + expect(adoptions).toHaveLength(1); + expect(adoptions[0]!.source).toBe('sdk-derived'); + expect(adoptions[0]!.url.href).toBe('https://mcp.example.com/'); + expect(adoptions[0]!.producer.url.href).toBe(SERVER_URL); + expect(adoptions[0]!.producer.kind).toBe('mcp-server'); + // The hook also ran before every request the discovery issued. + expect(seenContexts.filter(ctx => ctx.purpose === 'resource-metadata').length).toBeGreaterThan(0); + expect(seenContexts.filter(ctx => ctx.purpose === 'as-metadata').length).toBeGreaterThan(0); + }); + + it('auth() forwards the provider trust members and a rejecting validateDiscoveryURL fails the flow pre-request', async () => { + const seenContexts: DiscoveryUrlContext[] = []; + const provider = createProvider({ + validateDiscoveryURL: vi.fn((ctx: DiscoveryUrlContext) => { + seenContexts.push(ctx); + if (ctx.purpose === 'authorization-server') { + throw new DiscoveryUrlBlockedError(ctx, 'not on the application trust list'); + } + }) + }); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + + const adoption = seenContexts.find(ctx => ctx.purpose === 'authorization-server'); + expect(adoption?.source).toBe('protected-resource-metadata'); + expect(adoption?.url.href).toBe('https://auth.example.com/'); + expect(adoption?.producer.url.href).toBe(SERVER_URL); + expect(adoption?.producer.kind).toBe('mcp-server'); + // Rejected before any request reached the authorization server. + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('a hook throw that is not DiscoveryUrlBlockedError is wrapped, so callers see one failure type', async () => { + const cause = new Error('issuer not in the tenant directory'); + const provider = createProvider({ + validateDiscoveryURL: vi.fn((ctx: DiscoveryUrlContext) => { + if (ctx.purpose === 'as-metadata') { + throw cause; + } + }) + }); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + const failure = await auth(provider, { serverUrl: SERVER_URL, fetchFn }).then( + () => undefined, + error => error as Error + ); + + expect(failure).toBeInstanceOf(DiscoveryUrlBlockedError); + expect(failure!.message).toContain('validateDiscoveryURL'); + expect(failure!.cause).toBe(cause); + // The rejected request never left the client. + expect(requestedUrls(fetchFn).some(url => url.includes('oauth-authorization-server'))).toBe(false); + }); + + it('the hook gates the authorization endpoint before the user agent is redirected to it', async () => { + const provider = createProvider({ + validateDiscoveryURL: vi.fn((ctx: DiscoveryUrlContext) => { + if (ctx.purpose === 'authorization-endpoint') { + throw new DiscoveryUrlBlockedError(ctx, 'authorization endpoint not on the application trust list'); + } + }) + }); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(provider.redirectToAuthorization).not.toHaveBeenCalled(); + }); + + it('auth() applies the provider trustedAuthorizationServers list to adoption', async () => { + const provider = createProvider({ trustedAuthorizationServers: ['https://trusted.example.com'] }); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + }); + + describe('cached discovery state restore', () => { + it('re-validates the cached authorization server URL and throws with zero requests when it violates policy', async () => { + const provider = createProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'http://169.254.169.254', + resourceMetadata: resourceMetadataFor(SERVER_URL, 'http://169.254.169.254'), + authorizationServerMetadata: authServerMetadataFor('http://169.254.169.254') + }), + saveDiscoveryState: vi.fn() + }); + const fetchFn = vi.fn(); + + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('applies the provider trust verification to a restored authorization server URL', async () => { + const provider = createProvider({ + trustedAuthorizationServers: ['https://trusted.example.com'], + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: resourceMetadataFor(SERVER_URL, 'https://auth.example.com'), + authorizationServerMetadata: authServerMetadataFor('https://auth.example.com') + }), + saveDiscoveryState: vi.fn() + }); + const fetchFn = vi.fn(); + + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + }); + + describe('provider-level discovery policy (OAuthClientProvider.discoveryPolicy)', () => { + const PRIVATE_AS = 'https://10.1.2.3'; + + function privateAsRoutes(): Record Response> { + return { + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, PRIVATE_AS)), + [`${PRIVATE_AS}/.well-known/oauth-authorization-server`]: () => jsonResponse(authServerMetadataFor(PRIVATE_AS)) + }; + } + + function unauthorizedResponse(): Response { + return { + ok: false, + status: 401, + headers: new Headers({ 'www-authenticate': 'Bearer realm="mcp"' }), + text: async () => '' + } as unknown as Response; + } + + it('auth() reads the policy from the provider when the call passes none', async () => { + const provider = createProvider({ discoveryPolicy: { allowPrivateAddressTargets: true } }); + const fetchFn = routedFetch(privateAsRoutes()); + + const result = await auth(provider, { serverUrl: SERVER_URL, fetchFn }); + + expect(result).toBe('REDIRECT'); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + }); + + it('a per-call discoveryPolicy takes precedence over the provider policy', async () => { + const provider = createProvider({ discoveryPolicy: { allowPrivateAddressTargets: true } }); + const fetchFn = routedFetch(privateAsRoutes()); + + // An explicit per-call policy (here: no overrides at all) replaces the + // provider policy wholesale — the effective policy is resolved once at + // flow entry, not merged per option. + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn, discoveryPolicy: {} })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedHosts(fetchFn)).not.toContain('10.1.2.3'); + }); + + it('the provider policy reaches a transport-driven 401 flow (allowPrivateAddressTargets)', async () => { + const provider = createProvider({ discoveryPolicy: { allowPrivateAddressTargets: true } }); + const routes = privateAsRoutes(); + const fetchFn = vi.fn(async (url: string | URL) => { + const urlString = url.toString(); + if (urlString === SERVER_URL) { + return unauthorizedResponse(); + } + for (const [prefix, respond] of Object.entries(routes)) { + if (urlString === prefix || urlString.startsWith(prefix)) { + return respond(); + } + } + throw new Error(`Unrouted fetch: ${urlString}`); + }); + const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { authProvider: provider, fetch: fetchFn }); + + // The flow ends at the interactive redirect, which the transport surfaces + // as UnauthorizedError — the private https target was reached and adopted + // because the provider policy permits it. + await expect(transport.send({ jsonrpc: '2.0', id: 1, method: 'ping' })).rejects.toThrow(UnauthorizedError); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + const redirectTarget = (provider.redirectToAuthorization as Mock).mock.calls[0]![0] as URL; + expect(redirectTarget.origin).toBe(PRIVATE_AS); + }); + + it('without the provider policy the same transport-driven flow fails closed before any request to the private target', async () => { + const provider = createProvider(); + const fetchFn = vi.fn(async (url: string | URL) => { + const urlString = url.toString(); + if (urlString === SERVER_URL) { + return unauthorizedResponse(); + } + if (urlString === PRM_WELL_KNOWN) { + return jsonResponse(resourceMetadataFor(SERVER_URL, PRIVATE_AS)); + } + throw new Error(`Unrouted fetch: ${urlString}`); + }); + const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { authProvider: provider, fetch: fetchFn }); + + await expect(transport.send({ jsonrpc: '2.0', id: 1, method: 'ping' })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedHosts(fetchFn)).not.toContain('10.1.2.3'); + }); + + it('a provider trust list is applied through a transport-driven flow without any per-call configuration', async () => { + const provider = createProvider({ trustedAuthorizationServers: ['https://trusted.example.com'] }); + const fetchFn = vi.fn(async (url: string | URL) => { + const urlString = url.toString(); + if (urlString === SERVER_URL) { + return unauthorizedResponse(); + } + if (urlString === PRM_WELL_KNOWN) { + return jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')); + } + throw new Error(`Unrouted fetch: ${urlString}`); + }); + const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { authProvider: provider, fetch: fetchFn }); + + // RFC 9728 §7.6: the adopted authorization server matches no trust-list + // entry, so the flow fails closed before its metadata is requested. + await expect(transport.send({ jsonrpc: '2.0', id: 1, method: 'ping' })).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedHosts(fetchFn)).not.toContain('auth.example.com'); + }); + }); + + describe('resource metadata URL provenance (resourceMetadataUrlSource)', () => { + const CROSS_ORIGIN_PRM = 'https://other.example.com/prm'; + + it('a non-conformant challenge-relayed metadata URL warns and falls back through a transport-driven 401 flow', async () => { + const provider = createProvider(); + const fetchFn = challengeRelayFetch(CROSS_ORIGIN_PRM); + const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { authProvider: provider, fetch: fetchFn }); + + // The transport relays the challenge's URL with its provenance on record, + // so the RFC 9728 §3 origin rejection is handled by falling back to the + // SDK's own well-known derivation; the flow then proceeds to the + // interactive redirect (surfaced by the transport as UnauthorizedError). + await expect(transport.send({ jsonrpc: '2.0', id: 1, method: 'ping' })).rejects.toThrow(UnauthorizedError); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + expect(requestedHosts(fetchFn)).not.toContain('other.example.com'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('well-known')); + }); + + it('a non-conformant challenge-relayed metadata URL warns and falls back through the withOAuth middleware', async () => { + const provider = createProvider(); + const fetchFn = challengeRelayFetch(CROSS_ORIGIN_PRM); + const enhancedFetch = withOAuth(provider, SERVER_URL)(fetchFn); + + // The middleware relays the challenge's URL with its provenance on record, + // exactly like the transports: the RFC 9728 §3 origin rejection falls back + // to the SDK's own well-known derivation, and the flow proceeds to the + // interactive redirect (surfaced by the middleware as UnauthorizedError) + // instead of failing on the non-conformant URL. + await expect(enhancedFetch(SERVER_URL)).rejects.toThrow(UnauthorizedError); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + expect(requestedHosts(fetchFn)).not.toContain('other.example.com'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('well-known')); + }); + + it('a challenge-relayed metadata URL whose response is a filtered redirect warns and falls back to the well-known derivation', async () => { + // The URL is conformant (same-origin), so it passes the pre-request + // check and is fetched — but the runtime filters the redirect it + // answers with. Same handling as a policy rejection of the URL: + // warn, then derive the well-known location. + const sameOriginPrm = 'https://mcp.example.com/custom-prm'; + const fetchFn = routedFetch({ + [sameOriginPrm]: () => opaqueRedirectResponse(), + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + const metadata = await discoverOAuthProtectedResourceMetadata( + SERVER_URL, + { resourceMetadataUrl: sameOriginPrm, resourceMetadataUrlSource: 'www-authenticate' }, + fetchFn + ); + + expect(metadata.resource).toBe(SERVER_URL); + expect(requestedUrls(fetchFn)).toEqual([sameOriginPrm, PRM_WELL_KNOWN]); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('well-known')); + }); + + it('a caller-configured metadata URL whose response is a filtered redirect fails without falling back', async () => { + const sameOriginPrm = 'https://mcp.example.com/custom-prm'; + const fetchFn = routedFetch({ + [sameOriginPrm]: () => opaqueRedirectResponse() + }); + + await expect( + discoverOAuthProtectedResourceMetadata(SERVER_URL, { resourceMetadataUrl: sameOriginPrm }, fetchFn) + ).rejects.toThrow(RedirectFilteredResponseError); + // No well-known probe: a caller-configured URL is a configuration + // statement, so discovery does not proceed at a different location. + expect(requestedUrls(fetchFn)).toEqual([sameOriginPrm]); + }); + + it('a filtered redirect on a caller-configured metadata URL fails discoverOAuthServerInfo instead of degrading to the legacy fallback', async () => { + const sameOriginPrm = 'https://mcp.example.com/custom-prm'; + const fetchFn = routedFetch({ + [sameOriginPrm]: () => opaqueRedirectResponse() + }); + + await expect(discoverOAuthServerInfo(SERVER_URL, { resourceMetadataUrl: new URL(sameOriginPrm), fetchFn })).rejects.toThrow( + RedirectFilteredResponseError + ); + // No fallback request to the server-derived authorization server. + expect(requestedUrls(fetchFn)).toEqual([sameOriginPrm]); + }); + + it('the warn-and-fall-back pass does not record the set-aside challenge URL, so a later restore from state succeeds', async () => { + // First pass: the challenge relays a cross-origin URL; discovery warns, + // falls back to the well-known derivation, and reaches the redirect. + const saveDiscoveryState = vi.fn(); + const firstProvider = createProvider({ + discoveryState: vi.fn().mockResolvedValue(undefined), + saveDiscoveryState + }); + const firstFetch = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/': () => jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + const firstResult = await auth(firstProvider, { + serverUrl: SERVER_URL, + resourceMetadataUrl: new URL(CROSS_ORIGIN_PRM), + resourceMetadataUrlSource: 'www-authenticate', + fetchFn: firstFetch + }); + expect(firstResult).toBe('REDIRECT'); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('well-known')); + const savedStates = saveDiscoveryState.mock.calls.map(call => call[0] as Record); + expect(savedStates.length).toBeGreaterThan(0); + for (const state of savedStates) { + expect(state.resourceMetadataUrl).toBeUndefined(); + } + + // Second pass: restore from a provider that persisted only the URLs + // (metadata documents not round-tripped). Nothing recorded on the + // first pass re-enters the flow as a caller-configured URL, so the + // restored flow reaches the redirect again. + const lastSaved = savedStates[savedStates.length - 1]!; + const secondProvider = createProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: lastSaved.authorizationServerUrl, + resourceMetadataUrl: lastSaved.resourceMetadataUrl + }), + saveDiscoveryState: vi.fn() + }); + const secondFetch = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/': () => jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + const secondResult = await auth(secondProvider, { serverUrl: SERVER_URL, fetchFn: secondFetch }); + expect(secondResult).toBe('REDIRECT'); + expect(requestedHosts(secondFetch)).not.toContain('other.example.com'); + }); + + it('the same non-conformant URL configured by the caller fails the flow closed instead of falling back', async () => { + const provider = createProvider(); + const fetchFn = vi.fn(); + + await expect( + auth(provider, { serverUrl: SERVER_URL, fetchFn, resourceMetadataUrl: new URL(CROSS_ORIGIN_PRM) }) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('a non-conformant resourceMetadataUrl restored from cached discovery state fails closed like a caller-configured one', async () => { + // The cached restore is re-validated on every auth() call; a rejection + // does not fall back to the well-known derivation. + const provider = createProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: CROSS_ORIGIN_PRM + }), + saveDiscoveryState: vi.fn() + }); + const fetchFn = routedFetch({ + 'https://auth.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + await expect(auth(provider, { serverUrl: SERVER_URL, fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + // The rejected URL is never requested, and there is no warn-and-fall-back + // to the SDK's own well-known derivation. + expect(requestedHosts(fetchFn)).not.toContain('other.example.com'); + expect(requestedUrls(fetchFn)).not.toContain(PRM_WELL_KNOWN); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('well-known')); + }); + }); + + describe('endpoint validation from authorization server metadata', () => { + it('registerClient rejects a non-loopback http registration endpoint before the request', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + registration_endpoint: 'http://auth.example.com/register' + }; + const fetchFn = vi.fn(); + + await expect( + registerClient('https://auth.example.com', { + metadata, + clientMetadata: { redirect_uris: ['http://localhost:3000/callback'] }, + fetchFn + }) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('registerClient rejects a loopback registration endpoint published by a remote authorization server', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + registration_endpoint: 'https://127.0.0.1:9000/register' + }; + const fetchFn = vi.fn(); + + await expect( + registerClient('https://auth.example.com', { + metadata, + clientMetadata: { redirect_uris: ['http://localhost:3000/callback'] }, + fetchFn + }) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('startAuthorization rejects a non-loopback http authorization endpoint (asserted, never requested)', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + authorization_endpoint: 'http://auth.example.com/authorize' + }; + + await expect( + startAuthorization('https://auth.example.com', { + metadata, + clientInformation: { client_id: 'client-1' }, + redirectUrl: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(DiscoveryUrlBlockedError); + }); + + it('startAuthorization keeps a loopback authorization server working without metadata', async () => { + const { authorizationUrl } = await startAuthorization('http://localhost:9000', { + clientInformation: { client_id: 'client-1' }, + redirectUrl: 'http://localhost:3000/callback' + }); + expect(authorizationUrl.origin).toBe('http://localhost:9000'); + }); + }); + + describe('token endpoint validation from authorization server metadata (RFC 6749)', () => { + function exchangeOpts(metadata: AuthorizationServerMetadata, fetchFn: Mock) { + return { + metadata, + clientInformation: { client_id: 'client-1' }, + authorizationCode: 'auth-code', + codeVerifier: 'test_verifier', + redirectUri: 'http://localhost:3000/callback', + fetchFn + }; + } + + it('rejects a loopback token endpoint published by a remote authorization server, before any request', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + token_endpoint: 'http://127.0.0.1:8845/token' + }; + const fetchFn = vi.fn(); + + await expect(exchangeAuthorization('https://auth.example.com', exchangeOpts(metadata, fetchFn))).rejects.toThrow( + DiscoveryUrlBlockedError + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('rejects a private-range https token endpoint (locality rule), before any request', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + token_endpoint: 'https://192.168.1.1/token' + }; + const fetchFn = vi.fn(); + + await expect(exchangeAuthorization('https://auth.example.com', exchangeOpts(metadata, fetchFn))).rejects.toThrow( + DiscoveryUrlBlockedError + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('refreshAuthorization applies the same validation before any request', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + token_endpoint: 'https://10.0.0.8/token' + }; + const fetchFn = vi.fn(); + + await expect( + refreshAuthorization('https://auth.example.com', { + metadata, + clientInformation: { client_id: 'client-1' }, + refreshToken: 'refresh-token', + fetchFn + }) + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('allowPrivateAddressTargets permits a private-literal token endpoint explicitly', async () => { + const metadata = { + ...authServerMetadataFor('https://auth.example.com'), + token_endpoint: 'https://10.0.0.8/token' + }; + const fetchFn = vi.fn(async () => jsonResponse({ access_token: 'token-1', token_type: 'bearer' })); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + ...exchangeOpts(metadata, fetchFn), + discoveryPolicy: { allowPrivateAddressTargets: true } + }); + expect(tokens.access_token).toBe('token-1'); + expect(requestedUrls(fetchFn)).toEqual(['https://10.0.0.8/token']); + }); + + it('keeps a loopback authorization server with its loopback token endpoint working (local development)', async () => { + const metadata = authServerMetadataFor('http://localhost:9000'); + const fetchFn = vi.fn(async () => jsonResponse({ access_token: 'token-1', token_type: 'bearer' })); + + const tokens = await exchangeAuthorization('http://localhost:9000', exchangeOpts(metadata, fetchFn)); + expect(tokens.access_token).toBe('token-1'); + expect(requestedUrls(fetchFn)).toEqual(['http://localhost:9000/token']); + }); + + it('a redirected token response is an error and the request is not re-sent (RFC 6749: token responses are terminal)', async () => { + const metadata = authServerMetadataFor('https://auth.example.com'); + const fetchFn = vi.fn( + async (_url: string | URL, _init?: RequestInit) => + new Response(null, { status: 307, headers: { location: 'https://elsewhere.example.com/token' } }) + ); + + await expect(exchangeAuthorization('https://auth.example.com', exchangeOpts(metadata, fetchFn))).rejects.toThrow(/HTTP 307/); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(requestedUrls(fetchFn)).toEqual(['https://auth.example.com/token']); + expect(fetchFn.mock.calls[0]![1]?.redirect).toBe('manual'); + }); + + it('a filtered redirect on a token request fails with a message that does not point at the runtime', async () => { + const metadata = authServerMetadataFor('https://auth.example.com'); + const fetchFn = vi.fn(async () => opaqueRedirectResponse()); + + let caught: unknown; + try { + await exchangeAuthorization('https://auth.example.com', exchangeOpts(metadata, fetchFn)); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RedirectFilteredResponseError); + const filtered = caught as RedirectFilteredResponseError; + expect(filtered.purpose).toBe('token-endpoint'); + expect(filtered.url.href).toBe('https://auth.example.com/token'); + // Redirected responses to these POSTs are terminal in every runtime, so + // the message must not suggest a runtime change as a remedy. + expect(filtered.message).toContain('never followed, in any runtime'); + expect(filtered.message).not.toContain('runtime that exposes redirect responses'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('a filtered redirect on a registration request fails the same way', async () => { + const metadata = authServerMetadataFor('https://auth.example.com'); + const fetchFn = vi.fn(async () => opaqueRedirectResponse()); + + await expect( + registerClient('https://auth.example.com', { + metadata, + clientMetadata: { redirect_uris: ['http://localhost:3000/callback'] }, + fetchFn + }) + ).rejects.toThrow(RedirectFilteredResponseError); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('a redirected registration response is an error and the request is not re-sent', async () => { + const metadata = authServerMetadataFor('https://auth.example.com'); + const fetchFn = vi.fn(async (_url: string | URL, _init?: RequestInit) => + redirectResponse(307, 'https://elsewhere.example.com/register') + ); + + await expect( + registerClient('https://auth.example.com', { + metadata, + clientMetadata: { redirect_uris: ['http://localhost:3000/callback'] }, + fetchFn + }) + ).rejects.toThrow(RegistrationRejectedError); + + expect(fetchFn).toHaveBeenCalledTimes(1); + expect(requestedUrls(fetchFn)).toEqual(['https://auth.example.com/register']); + expect(fetchFn.mock.calls[0]![1]?.redirect).toBe('manual'); + }); + }); + + describe('redirect handling on discovery requests', () => { + it('requests discovery URLs with redirect: "manual" and follows a conformant hop', async () => { + const redirected = 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp/'; + const fetchFn = vi.fn(async (url: string | URL, _init?: RequestInit) => { + const urlString = url.toString(); + if (urlString === PRM_WELL_KNOWN) { + return redirectResponse(301, redirected); + } + if (urlString === redirected) { + return jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')); + } + throw new Error(`Unrouted fetch: ${urlString}`); + }); + + const metadata = await discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn); + expect(metadata.resource).toBe(SERVER_URL); + + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN, redirected]); + for (const call of fetchFn.mock.calls) { + expect(call[1]?.redirect).toBe('manual'); + } + }); + + it('rejects a redirect hop that violates the policy, with zero requests to the hop target', async () => { + const fetchFn = vi.fn(async (url: string | URL) => { + if (url.toString() === PRM_WELL_KNOWN) { + return redirectResponse(302, 'http://169.254.169.254/latest/meta-data/'); + } + throw new Error(`Unrouted fetch: ${url}`); + }); + + let caught: unknown; + try { + await discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DiscoveryUrlBlockedError); + const blocked = caught as DiscoveryUrlBlockedError; + expect(blocked.context.purpose).toBe('redirect-hop'); + expect(blocked.context.redirectHop?.originalPurpose).toBe('resource-metadata'); + expect(blocked.context.redirectHop?.status).toBe(302); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('a filtered redirect on the derived well-known probes is fallback-eligible like a 404 and surfaces when no probe answers', async () => { + const fetchFn = vi.fn(async () => opaqueRedirectResponse()); + + let caught: unknown; + try { + await discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RedirectFilteredResponseError); + const filtered = caught as RedirectFilteredResponseError; + expect(filtered.purpose).toBe('resource-metadata'); + expect(filtered.message).toContain('redirect target cannot be observed'); + // The path-aware probe's filtered redirect is treated like a 404: the + // root probe is still attempted before the failure surfaces. No + // redirect is ever followed — the responses carry no readable Location. + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN, 'https://mcp.example.com/.well-known/oauth-protected-resource']); + }); + + it('a filtered redirect that falls back to a retrievable root document does not surface at all', async () => { + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => opaqueRedirectResponse(), + 'https://mcp.example.com/.well-known/oauth-protected-resource': () => + jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')) + }); + + const metadata = await discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn); + expect(metadata.resource).toBe(SERVER_URL); + }); + + it('a filtered redirect on a followed hop reports the hop stage, like other hop-level failures', async () => { + const callerPrm = 'https://mcp.example.com/custom-prm'; + const sameOriginHop = 'https://mcp.example.com/prm-moved'; + const fetchFn = vi.fn(async (url: string | URL) => { + if (url.toString() === callerPrm) { + return redirectResponse(307, sameOriginHop); + } + return opaqueRedirectResponse(); + }); + + let caught: unknown; + try { + await discoverOAuthProtectedResourceMetadata(SERVER_URL, { resourceMetadataUrl: callerPrm }, fetchFn); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RedirectFilteredResponseError); + const filtered = caught as RedirectFilteredResponseError; + expect(filtered.purpose).toBe('redirect-hop'); + expect(filtered.url.href).toBe(sameOriginHop); + // The hop that answered with the filtered redirect; its target is not + // observable, so `from` names the answering URL and `status` is the + // filtered response's literal 0. + expect(filtered.redirectHop?.from.href).toBe(sameOriginHop); + expect(filtered.redirectHop?.status).toBe(0); + expect(filtered.redirectHop?.originalPurpose).toBe('resource-metadata'); + expect(requestedUrls(fetchFn)).toEqual([callerPrm, sameOriginHop]); + }); + + it('a filtered redirect on the derived probe degrades discoverOAuthServerInfo to the legacy fallback, like a 404', async () => { + const fetchFn = routedFetch({ + 'https://mcp.example.com/.well-known/oauth-protected-resource': () => opaqueRedirectResponse(), + 'https://mcp.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://mcp.example.com')) + }); + + const info = await discoverOAuthServerInfo(SERVER_URL, { fetchFn }); + + expect(info.resourceMetadata).toBeUndefined(); + expect(info.authorizationServerUrl).toBe('https://mcp.example.com/'); + expect(info.authorizationServerMetadata?.issuer).toBe('https://mcp.example.com'); + }); + + it('an authorization-server metadata candidate that answers with a filtered redirect is skipped, like a 404', async () => { + const fetchFn = routedFetch({ + 'https://auth.example.com/.well-known/oauth-authorization-server': () => opaqueRedirectResponse(), + 'https://auth.example.com/.well-known/openid-configuration': () => + jsonResponse({ + ...authServerMetadataFor('https://auth.example.com'), + jwks_uri: 'https://auth.example.com/jwks', + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'] + }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { fetchFn }); + + expect(metadata?.issuer).toBe('https://auth.example.com'); + expect(requestedUrls(fetchFn)).toEqual([ + 'https://auth.example.com/.well-known/oauth-authorization-server', + 'https://auth.example.com/.well-known/openid-configuration' + ]); + }); + + it('a deployment whose web tier answers every well-known path with a redirect still reaches the authorization redirect', async () => { + // The full degradation chain in one flow: the protected-resource probe + // and every authorization-server metadata candidate answer with + // filtered redirects, so discovery degrades to the legacy + // server-derived authorization server with its default endpoint paths. + const provider = createProvider(); + const fetchFn = routedFetch({ + 'https://mcp.example.com/.well-known/': () => opaqueRedirectResponse() + }); + + const result = await auth(provider, { serverUrl: SERVER_URL, fetchFn }); + + expect(result).toBe('REDIRECT'); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + const redirectTarget = (provider.redirectToAuthorization as Mock).mock.calls[0]![0] as URL; + expect(redirectTarget.origin).toBe('https://mcp.example.com'); + expect(redirectTarget.pathname).toBe('/authorize'); + }); + + it('stops following after three hops and surfaces the remaining redirect response', async () => { + let counter = 0; + const fetchFn = vi.fn(async () => { + counter += 1; + return redirectResponse(302, `https://mcp.example.com/hop-${counter}`); + }); + + await expect(discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn)).rejects.toThrow('HTTP 302'); + // Initial request plus at most three followed hops. + expect(fetchFn).toHaveBeenCalledTimes(4); + }); + + it('drops request headers when a hop leaves the current origin', async () => { + const crossOriginTarget = 'https://cdn.example.com/oauth-protected-resource.json'; + const fetchFn = vi.fn(async (url: string | URL, _init?: RequestInit) => { + if (url.toString() === PRM_WELL_KNOWN) { + return redirectResponse(307, crossOriginTarget); + } + if (url.toString() === crossOriginTarget) { + return jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')); + } + throw new Error(`Unrouted fetch: ${url}`); + }); + + await discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn); + + const [firstCall, secondCall] = fetchFn.mock.calls; + expect(normalizeHeaders(firstCall![1]?.headers)).not.toEqual({}); + // The cross-origin request carries the sentinel so that a wrapping + // fetch (createFetchWithInit) does not fall back to its base headers. + expect(secondCall![1]?.headers).toBe(OMIT_BASE_HEADERS); + }); + + it('base headers from a wrapping fetch stay on same-origin hops but are dropped when a hop leaves the origin', async () => { + const sameOriginHop = 'https://mcp.example.com/prm-moved'; + const crossOriginTarget = 'https://cdn.example.com/oauth-protected-resource.json'; + const innerFetch = vi.fn(async (url: string | URL, _init?: RequestInit) => { + const urlString = url.toString(); + if (urlString === PRM_WELL_KNOWN) { + return redirectResponse(307, sameOriginHop); + } + if (urlString === sameOriginHop) { + return redirectResponse(307, crossOriginTarget); + } + if (urlString === crossOriginTarget) { + return jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')); + } + throw new Error(`Unrouted fetch: ${url}`); + }); + // The shape the transports build for oauthRequestInit: a wrapper that + // merges base headers into every request it issues. + const fetchFn = createFetchWithInit(innerFetch, { headers: { 'x-auth-gateway': 'gateway-value' } }); + + await discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn); + + const headersPerRequest = innerFetch.mock.calls.map(call => normalizeHeaders(call[1]?.headers)); + expect(headersPerRequest).toHaveLength(3); + expect(headersPerRequest[0]!['x-auth-gateway']).toBe('gateway-value'); + expect(headersPerRequest[1]!['x-auth-gateway']).toBe('gateway-value'); + // Headers never travel across origins on a redirect — the base headers + // are not re-applied on the cross-origin request either. + expect(headersPerRequest[2]).toEqual({}); + }); + + it('a rejected hop on the protected-resource-metadata request fails discoverOAuthServerInfo closed (no legacy fallback)', async () => { + const fetchFn = vi.fn(async (url: string | URL) => { + if (url.toString() === PRM_WELL_KNOWN) { + return redirectResponse(302, 'http://192.168.1.10/prm'); + } + throw new Error(`Unrouted fetch: ${url}`); + }); + + await expect(discoverOAuthServerInfo(SERVER_URL, { fetchFn })).rejects.toThrow(DiscoveryUrlBlockedError); + // Fail closed: no fallback request to the server-derived authorization server. + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN]); + }); + + it('re-validates hops on authorization server metadata discovery as well', async () => { + const asWellKnown = 'https://auth.example.com/.well-known/oauth-authorization-server'; + const fetchFn = vi.fn(async (url: string | URL) => { + if (url.toString() === asWellKnown) { + return redirectResponse(302, 'https://192.168.1.10/metadata'); + } + return notFoundResponse(); + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com', { fetchFn })).rejects.toThrow( + DiscoveryUrlBlockedError + ); + expect(requestedUrls(fetchFn)).toEqual([asWellKnown]); + }); + }); + + describe('validation inside the discovery fetch path', () => { + it('discoverAuthorizationServerMetadata fails closed before any request when the derived discovery URL violates the policy', async () => { + // A non-loopback http authorization server derives non-loopback http + // discovery URLs; the fetch path rejects them before the first request. + const fetchFn = vi.fn(); + + await expect(discoverAuthorizationServerMetadata('http://as.example.com', { fetchFn })).rejects.toThrow( + DiscoveryUrlBlockedError + ); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('the root-fallback discovery request is validated in the fetch path too (a policy-violating hop off it is rejected)', async () => { + const rootWellKnown = 'https://mcp.example.com/.well-known/oauth-protected-resource'; + const fetchFn = vi.fn(async (url: string | URL) => { + if (url.toString() === PRM_WELL_KNOWN) { + return notFoundResponse(); + } + if (url.toString() === rootWellKnown) { + return redirectResponse(302, 'http://192.168.1.10/prm'); + } + throw new Error(`Unrouted fetch: ${url}`); + }); + + await expect(discoverOAuthProtectedResourceMetadata(SERVER_URL, undefined, fetchFn)).rejects.toThrow(DiscoveryUrlBlockedError); + expect(requestedUrls(fetchFn)).toEqual([PRM_WELL_KNOWN, rootWellKnown]); + }); + }); + + describe('full flow shape', () => { + it('an interactive flow against a cross-origin https authorization server proceeds to redirect (default policy)', async () => { + const provider = createProvider({ + clientInformation: vi.fn().mockResolvedValue({ client_id: 'client-1' }), + saveDiscoveryState: vi.fn(), + discoveryState: vi.fn().mockResolvedValue(undefined) + }); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(resourceMetadataFor(SERVER_URL, 'https://auth.example.com')), + 'https://auth.example.com/.well-known/oauth-authorization-server': () => + jsonResponse(authServerMetadataFor('https://auth.example.com')) + }); + + const result = await auth(provider, { serverUrl: SERVER_URL, fetchFn }); + expect(result).toBe('REDIRECT'); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/client/test/client/authRequestGating.test.ts b/packages/client/test/client/authRequestGating.test.ts new file mode 100644 index 0000000000..bc62f6583a --- /dev/null +++ b/packages/client/test/client/authRequestGating.test.ts @@ -0,0 +1,480 @@ +/** + * Structural coverage of the discovery URL policy across the client OAuth flow: + * every request the flow issues, and every discovery-derived URL it adopts, must + * pass through {@linkcode assertAllowedDiscoveryUrl} before the request (or + * adoption) happens. + * + * Unlike the per-rule wiring tests in `authDiscoveryPolicy.test.ts`, these tests + * instrument the gate itself and drive the full flow legs — discovery, + * registration, authorization redirect, code exchange (callback leg), token + * refresh, and the Cross-App Access token exchanges — asserting for each leg that: + * + * 1. the recorded gate purposes cover exactly the expected set, + * 2. every URL handed to `fetchFn` was validated before that request was issued, and + * 3. the provider's `validateDiscoveryURL` hook ran for exactly the same + * (purpose, URL) set as the mechanical gate — every checkpoint invokes both. + * (The standalone Cross-App Access helpers run without a provider, so only + * assertions 1–2 apply to their legs.) + * + * A future code path that issues a request without the gate fails assertion 2; + * one that gates a URL without threading the provider hook fails assertion 3; + * a new {@linkcode DiscoveryUrlPurpose} member that no flow leg produces fails + * the exhaustiveness check at the bottom (the `Record` forces the list to be + * updated, and the assertion forces a leg to produce it). + */ +import type { AuthorizationServerMetadata, DiscoveryUrlContext, DiscoveryUrlPurpose } from '@modelcontextprotocol/core-internal'; +import { assertAllowedDiscoveryUrl } from '@modelcontextprotocol/core-internal'; +import type { Mock } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { OAuthClientProvider } from '../../src/client/auth'; +import { auth } from '../../src/client/auth'; +import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess'; + +vi.mock('@modelcontextprotocol/core-internal', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + assertAllowedDiscoveryUrl: vi.fn(actual.assertAllowedDiscoveryUrl) + }; +}); + +// Mock pkce-challenge (startAuthorization generates a challenge before returning) +vi.mock('pkce-challenge', () => ({ + default: () => ({ + code_verifier: 'test_verifier', + code_challenge: 'test_challenge' + }) +})); + +const gateSpy = vi.mocked(assertAllowedDiscoveryUrl); + +const SERVER_URL = 'https://mcp.example.com/mcp'; +const PRM_WELL_KNOWN = 'https://mcp.example.com/.well-known/oauth-protected-resource/mcp'; +const AS_URL = 'https://auth.example.com'; +const AS_WELL_KNOWN = 'https://auth.example.com/.well-known/oauth-authorization-server'; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => body + } as unknown as Response; +} + +function redirectResponse(status: number, location: string): Response { + return { + ok: false, + status, + headers: new Headers({ location }), + text: async () => '' + } as unknown as Response; +} + +const AS_METADATA: AuthorizationServerMetadata = { + issuer: AS_URL, + authorization_endpoint: `${AS_URL}/authorize`, + token_endpoint: `${AS_URL}/token`, + registration_endpoint: `${AS_URL}/register`, + response_types_supported: ['code'] +}; + +const RESOURCE_METADATA = { + resource: SERVER_URL, + authorization_servers: [AS_URL] +}; + +const CLIENT_INFO = { + client_id: 'client-abc', + redirect_uris: ['http://localhost:3000/callback'] +}; + +const TOKENS = { + access_token: 'access-123', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'refresh-456' +}; + +/** A recording fetch mock routing by exact URL prefix; unrouted URLs reject loudly. */ +function routedFetch(routes: Record Response>): Mock { + return vi.fn(async (url: string | URL) => { + const urlString = url.toString(); + for (const [prefix, respond] of Object.entries(routes)) { + if (urlString === prefix || urlString.startsWith(prefix)) { + return respond(); + } + } + throw new Error(`Unrouted fetch: ${urlString}`); + }); +} + +/** + * Records every context the provider's `validateDiscoveryURL` hook receives. + * Cleared per test; compared against the gate's recorded contexts to assert the + * hook runs at every checkpoint. + */ +const hookSpy = vi.fn<(ctx: DiscoveryUrlContext) => void>(); + +function createProvider(overrides: Partial = {}): OAuthClientProvider { + return { + validateDiscoveryURL: hookSpy, + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn(), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test_verifier'), + ...overrides + }; +} + +/** Provider mid-flow: discovery state, client credentials, and code verifier persisted. */ +function createCallbackLegProvider(overrides: Partial = {}): OAuthClientProvider { + return createProvider({ + clientInformation: vi.fn().mockResolvedValue({ ...CLIENT_INFO, issuer: AS_URL }), + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: AS_URL, + resourceMetadata: RESOURCE_METADATA, + authorizationServerMetadata: AS_METADATA + }), + saveDiscoveryState: vi.fn(), + ...overrides + }); +} + +function recordedContexts(): DiscoveryUrlContext[] { + return gateSpy.mock.calls.map(call => call[0]); +} + +function recordedPurposes(): Set { + return new Set(recordedContexts().map(ctx => ctx.purpose)); +} + +/** + * The structural invariant: every URL handed to `fetchFn` had a gate call for + * that exact URL earlier in the run (vitest's `invocationCallOrder` is a global + * sequence across mocks, so "earlier" means before the request was issued). + */ +function expectEveryRequestValidatedFirst(fetchFn: Mock): void { + // Only gate calls for purposes the SDK requests may vouch for a request: + // 'authorization-server' and 'authorization-endpoint' are asserted without a + // request, and counting them here could mask a same-URL request elsewhere + // that skipped its own check. Missing order entries resolve so the + // containing assertion fails (fail closed). + const assertOnlyPurposes: ReadonlySet = new Set(['authorization-server', 'authorization-endpoint']); + const gateCalls = gateSpy.mock.calls + .map((call, index) => ({ + purpose: call[0].purpose, + href: call[0].url.href, + order: gateSpy.mock.invocationCallOrder[index] ?? Number.POSITIVE_INFINITY + })) + .filter(gate => !assertOnlyPurposes.has(gate.purpose)); + expect(fetchFn.mock.calls.length).toBeGreaterThan(0); + for (const [index, call] of fetchFn.mock.calls.entries()) { + const href = new URL(String(call[0])).href; + const order = fetchFn.mock.invocationCallOrder[index] ?? Number.NEGATIVE_INFINITY; + const validatedFirst = gateCalls.some(gate => gate.href === href && gate.order < order); + expect(validatedFirst, `request to ${href} was issued without a prior URL-policy check`).toBe(true); + } +} + +function contextKey(ctx: DiscoveryUrlContext): string { + return `${ctx.purpose} ${ctx.url.href}`; +} + +/** + * The hook invariant: the provider's `validateDiscoveryURL` hook received exactly + * the (purpose, URL) pairs the mechanical gate validated — no checkpoint runs the + * gate without also running the hook, and the hook is never invoked on a URL the + * gate did not see first. + */ +function expectHookMirrorsGate(): void { + const gateKeys = new Set(recordedContexts().map(contextKey)); + const hookKeys = new Set(hookSpy.mock.calls.map(call => contextKey(call[0]))); + expect(hookKeys).toEqual(gateKeys); +} + +/** + * Compile-time exhaustive list of purposes. Adding a member to + * {@linkcode DiscoveryUrlPurpose} fails this initializer until the new purpose + * is listed — and the exhaustiveness test below then requires a flow leg that + * actually produces it. + */ +const ALL_PURPOSES: Record = { + 'resource-metadata': true, + 'authorization-server': true, + 'as-metadata': true, + 'authorization-endpoint': true, + 'token-endpoint': true, + 'registration-endpoint': true, + 'redirect-hop': true +}; + +const producedPurposes = new Set(); + +function recordLeg(): void { + for (const purpose of recordedPurposes()) { + producedPurposes.add(purpose); + } +} + +describe('request gating across the client OAuth flow legs', () => { + beforeEach(() => { + gateSpy.mockClear(); + hookSpy.mockClear(); + }); + + it('discovery + registration + authorization redirect: every leg-specific purpose is gated and every request validated first', async () => { + const provider = createProvider(); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => jsonResponse(RESOURCE_METADATA), + [AS_WELL_KNOWN]: () => jsonResponse(AS_METADATA), + [`${AS_URL}/register`]: () => jsonResponse(CLIENT_INFO) + }); + + const result = await auth(provider, { serverUrl: SERVER_URL, fetchFn }); + + expect(result).toBe('REDIRECT'); + expect(provider.redirectToAuthorization).toHaveBeenCalledTimes(1); + expect(recordedPurposes()).toEqual( + new Set(['resource-metadata', 'authorization-server', 'as-metadata', 'registration-endpoint', 'authorization-endpoint']) + ); + expectEveryRequestValidatedFirst(fetchFn); + expectHookMirrorsGate(); + + // The registration request (a POST carrying the client metadata) was gated + // with the endpoint's own purpose, keyed on the authorization server that + // published the endpoint. + const registrationContext = recordedContexts().find(ctx => ctx.purpose === 'registration-endpoint'); + expect(registrationContext?.url.href).toBe(`${AS_URL}/register`); + expect(registrationContext?.producer.url.href).toBe(`${AS_URL}/`); + expect(registrationContext?.producer.kind).toBe('authorization-server'); + + // Discovery-phase contexts carry the MCP server as the producing step. + const resourceMetadataContext = recordedContexts().find(ctx => ctx.purpose === 'resource-metadata'); + expect(resourceMetadataContext?.producer.url.href).toBe(SERVER_URL); + expect(resourceMetadataContext?.producer.kind).toBe('mcp-server'); + + // The redirect target is asserted even though the SDK never requests it. + // (Compare origin + pathname: the flow appends the authorization request + // parameters to the same URL instance after the assertion.) + const authorizationEndpointContext = recordedContexts().find(ctx => ctx.purpose === 'authorization-endpoint'); + expect(authorizationEndpointContext && authorizationEndpointContext.url.origin + authorizationEndpointContext.url.pathname).toBe( + `${AS_URL}/authorize` + ); + + recordLeg(); + }); + + it('a redirected discovery response gates each Location target as a redirect-hop before following it', async () => { + const movedUrl = 'https://mcp.example.com/prm-moved'; + const provider = createProvider(); + const fetchFn = routedFetch({ + [PRM_WELL_KNOWN]: () => redirectResponse(302, movedUrl), + [movedUrl]: () => jsonResponse(RESOURCE_METADATA), + [AS_WELL_KNOWN]: () => jsonResponse(AS_METADATA), + [`${AS_URL}/register`]: () => jsonResponse(CLIENT_INFO) + }); + + const result = await auth(provider, { serverUrl: SERVER_URL, fetchFn }); + + expect(result).toBe('REDIRECT'); + expect(recordedPurposes()).toEqual( + new Set([ + 'resource-metadata', + 'redirect-hop', + 'authorization-server', + 'as-metadata', + 'registration-endpoint', + 'authorization-endpoint' + ]) + ); + expectEveryRequestValidatedFirst(fetchFn); + expectHookMirrorsGate(); + + const hopContext = recordedContexts().find(ctx => ctx.purpose === 'redirect-hop'); + expect(hopContext?.url.href).toBe(movedUrl); + expect(hopContext?.redirectHop?.originalPurpose).toBe('resource-metadata'); + + recordLeg(); + }); + + it('callback leg (authorization code exchange): the restored server URL and the token request are both gated', async () => { + const provider = createCallbackLegProvider(); + const fetchFn = routedFetch({ + [`${AS_URL}/token`]: () => jsonResponse(TOKENS) + }); + + const result = await auth(provider, { serverUrl: SERVER_URL, authorizationCode: 'code-789', fetchFn }); + + expect(result).toBe('AUTHORIZED'); + expect(provider.saveTokens).toHaveBeenCalledTimes(1); + expect(recordedPurposes()).toEqual(new Set(['authorization-server', 'token-endpoint'])); + expectEveryRequestValidatedFirst(fetchFn); + expectHookMirrorsGate(); + + // The authorization server URL restored from cached state is re-validated on + // every restore, with its provenance on record. + const restoreContext = recordedContexts().find(ctx => ctx.purpose === 'authorization-server'); + expect(restoreContext?.url.href).toBe(`${AS_URL}/`); + expect(restoreContext?.source).toBe('cached-discovery-state'); + expect(restoreContext?.producer.url.href).toBe(SERVER_URL); + expect(restoreContext?.producer.kind).toBe('mcp-server'); + + // The token request (the POST carrying the authorization code and verifier) + // is gated with the endpoint's own purpose before the request. + const tokenContext = recordedContexts().find(ctx => ctx.purpose === 'token-endpoint'); + expect(tokenContext?.url.href).toBe(`${AS_URL}/token`); + expect(tokenContext?.source).toBe('authorization-server-metadata'); + expect(tokenContext?.producer.url.href).toBe(`${AS_URL}/`); + expect(tokenContext?.producer.kind).toBe('authorization-server'); + + recordLeg(); + }); + + it('refresh leg: the refresh-token request is gated as a token-endpoint request', async () => { + const provider = createCallbackLegProvider({ + tokens: vi.fn().mockResolvedValue({ ...TOKENS, issuer: AS_URL }) + }); + const fetchFn = routedFetch({ + [`${AS_URL}/token`]: () => jsonResponse({ ...TOKENS, access_token: 'access-refreshed' }) + }); + + const result = await auth(provider, { serverUrl: SERVER_URL, fetchFn }); + + expect(result).toBe('AUTHORIZED'); + expect(provider.saveTokens).toHaveBeenCalledWith(expect.objectContaining({ access_token: 'access-refreshed' }), { + issuer: AS_URL + }); + expect(recordedPurposes()).toEqual(new Set(['authorization-server', 'token-endpoint'])); + expectEveryRequestValidatedFirst(fetchFn); + expectHookMirrorsGate(); + + const tokenContext = recordedContexts().find(ctx => ctx.purpose === 'token-endpoint'); + expect(tokenContext?.url.href).toBe(`${AS_URL}/token`); + + recordLeg(); + }); + + it('the flow legs above produce every declared DiscoveryUrlPurpose (no purpose is vocabulary-only)', () => { + // Runs after the leg tests in this file (vitest executes a file's tests in + // declaration order). If this fails, either a leg regressed or a new purpose + // was added without a flow leg that produces it. + expect([...producedPurposes].toSorted()).toEqual(Object.keys(ALL_PURPOSES).toSorted()); + }); +}); + +describe('request gating for the Cross-App Access token legs', () => { + // These standalone helpers run without an OAuthClientProvider, so only the + // mechanical gate applies — there is no validateDiscoveryURL hook on this path. + const IDP_URL = 'https://idp.example.com'; + const IDP_WELL_KNOWN = 'https://idp.example.com/.well-known/oauth-authorization-server'; + + const IDP_METADATA = { + issuer: IDP_URL, + authorization_endpoint: `${IDP_URL}/authorize`, + token_endpoint: `${IDP_URL}/token`, + response_types_supported: ['code'] + }; + + const JAG_RESPONSE = { + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A' + }; + + beforeEach(() => { + gateSpy.mockClear(); + }); + + it('requestJwtAuthorizationGrant: the exchange POST is gated as a token-endpoint request anchored on the caller-named endpoint', async () => { + const fetchFn = routedFetch({ + [`${IDP_URL}/token`]: () => jsonResponse(JAG_RESPONSE) + }); + + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: `${IDP_URL}/token`, + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'idp-client', + fetchFn + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + expect(recordedPurposes()).toEqual(new Set(['token-endpoint'])); + expectEveryRequestValidatedFirst(fetchFn); + + // A caller-supplied endpoint is its own locality anchor, with the caller + // provenance on record. + const tokenContext = recordedContexts().find(ctx => ctx.purpose === 'token-endpoint'); + expect(tokenContext?.url.href).toBe(`${IDP_URL}/token`); + expect(tokenContext?.source).toBe('caller'); + expect(tokenContext?.producer.url.href).toBe(`${IDP_URL}/token`); + expect(tokenContext?.producer.kind).toBe('authorization-server'); + }); + + it('discoverAndRequestJwtAuthGrant: discovery and the exchange POST are both gated, with the IdP issuer as the token-request producer', async () => { + const fetchFn = routedFetch({ + [IDP_WELL_KNOWN]: () => jsonResponse(IDP_METADATA), + [`${IDP_URL}/token`]: () => jsonResponse(JAG_RESPONSE) + }); + + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: IDP_URL, + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'idp-client', + fetchFn + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + expect(recordedPurposes()).toEqual(new Set(['as-metadata', 'token-endpoint'])); + expectEveryRequestValidatedFirst(fetchFn); + + // The endpoint was published by the IdP's metadata, so the IdP issuer + // anchors the locality check for the token request. + const tokenContext = recordedContexts().find(ctx => ctx.purpose === 'token-endpoint'); + expect(tokenContext?.url.href).toBe(`${IDP_URL}/token`); + expect(tokenContext?.source).toBe('authorization-server-metadata'); + expect(tokenContext?.producer.url.href).toBe(`${IDP_URL}/`); + expect(tokenContext?.producer.kind).toBe('authorization-server'); + }); + + it('exchangeJwtAuthGrant: the jwt-bearer POST is gated as a token-endpoint request before credentials are sent', async () => { + const fetchFn = routedFetch({ + [`${AS_URL}/token`]: () => jsonResponse(TOKENS) + }); + + const result = await exchangeJwtAuthGrant({ + tokenEndpoint: `${AS_URL}/token`, + jwtAuthGrant: 'jag-token', + clientId: 'mcp-client', + clientSecret: 'mcp-secret', + fetchFn + }); + + expect(result.access_token).toBe(TOKENS.access_token); + expect(recordedPurposes()).toEqual(new Set(['token-endpoint'])); + expectEveryRequestValidatedFirst(fetchFn); + + const tokenContext = recordedContexts().find(ctx => ctx.purpose === 'token-endpoint'); + expect(tokenContext?.url.href).toBe(`${AS_URL}/token`); + expect(tokenContext?.source).toBe('caller'); + expect(tokenContext?.producer.url.href).toBe(`${AS_URL}/token`); + expect(tokenContext?.producer.kind).toBe('authorization-server'); + }); +}); diff --git a/packages/client/test/client/errorBrandConformance.test.ts b/packages/client/test/client/errorBrandConformance.test.ts index 7265b8c29b..92b2c57f3d 100644 --- a/packages/client/test/client/errorBrandConformance.test.ts +++ b/packages/client/test/client/errorBrandConformance.test.ts @@ -58,6 +58,7 @@ describe('error brand conformance (client export surface)', () => { // errorSurfacePins.test.ts — this test only asserts the core re-export SET // so a rename there is not double-maintained here. const CORE_PINNED = new Set([ + 'DiscoveryUrlBlockedError', 'MissingRequiredClientCapabilityError', 'OAuthError', 'ProtocolError', @@ -80,6 +81,7 @@ describe('error brand conformance (client export surface)', () => { InsufficientScopeError: 'mcp.InsufficientScopeError', IssuerMismatchError: 'mcp.IssuerMismatchError', OAuthClientFlowError: 'mcp.OAuthClientFlowError', + RedirectFilteredResponseError: 'mcp.RedirectFilteredResponseError', RegistrationRejectedError: 'mcp.RegistrationRejectedError', SseError: 'mcp.SseError', UnauthorizedError: 'mcp.UnauthorizedError' diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index 70922f7d01..bed0006797 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -1,8 +1,9 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal'; -import { isJsonContentType } from '@modelcontextprotocol/core-internal'; +import { DiscoveryUrlBlockedError, isJsonContentType } from '@modelcontextprotocol/core-internal'; import type { Mocked, MockedFunction, MockInstance } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth'; +import { RedirectFilteredResponseError } from '../../src/client/authErrors'; import { applyMiddlewares, createMiddleware, withLogging, withOAuth } from '../../src/client/middleware'; vi.mock('../../src/client/auth', async () => { @@ -149,6 +150,7 @@ describe('withOAuth', () => { serverUrl: 'https://api.example.com', resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl, scope: mockWWWAuthenticateParams.scope, + resourceMetadataUrlSource: 'www-authenticate', fetchFn: mockFetch }); @@ -197,6 +199,7 @@ describe('withOAuth', () => { serverUrl: 'https://api.example.com', // Should be extracted from request URL resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl, scope: mockWWWAuthenticateParams.scope, + resourceMetadataUrlSource: 'www-authenticate', fetchFn: mockFetch }); @@ -241,6 +244,64 @@ describe('withOAuth', () => { await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Failed to re-authenticate: Network error'); }); + it('should rethrow DiscoveryUrlBlockedError from auth unwrapped so callers can branch on it', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + mockExtractWWWAuthenticateParams.mockReturnValue({}); + const blocked = new DiscoveryUrlBlockedError( + { + purpose: 'authorization-server', + url: new URL('https://auth.example.com'), + producer: { url: new URL('https://api.example.com'), kind: 'mcp-server' }, + source: 'protected-resource-metadata' + }, + 'the authorization server issuer does not match any trustedAuthorizationServers entry (RFC 9728 §7.6)' + ); + mockAuth.mockRejectedValue(blocked); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + const caught = await enhancedFetch('https://api.example.com/data').then( + () => undefined, + error => error as Error + ); + // The typed rejection propagates as-is — not flattened into an + // UnauthorizedError message string — so its context stays available. + expect(caught).toBe(blocked); + expect(caught).toBeInstanceOf(DiscoveryUrlBlockedError); + }); + + it('should rethrow RedirectFilteredResponseError from auth unwrapped as well', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + mockExtractWWWAuthenticateParams.mockReturnValue({}); + const filtered = new RedirectFilteredResponseError( + new URL('https://auth.example.com/token'), + 'token-endpoint', + 'redirected responses to this request are never followed, in any runtime (token responses are terminal, RFC 6749 §5); serve this endpoint without a redirect' + ); + mockAuth.mockRejectedValue(filtered); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + const caught = await enhancedFetch('https://api.example.com/data').then( + () => undefined, + error => error as Error + ); + expect(caught).toBe(filtered); + expect(caught).toBeInstanceOf(RedirectFilteredResponseError); + }); + it('should handle persistent 401 responses after auth', async () => { mockProvider.tokens.mockResolvedValue({ access_token: 'test-token', @@ -368,6 +429,7 @@ describe('withOAuth', () => { expect(mockAuth).toHaveBeenCalledWith(mockProvider, { serverUrl: 'https://api.example.com', // Should extract origin from URL object resourceMetadataUrl: undefined, + resourceMetadataUrlSource: 'www-authenticate', fetchFn: mockFetch }); }); @@ -911,6 +973,7 @@ describe('Integration Tests', () => { serverUrl: 'https://mcp-server.example.com', resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'), scope: 'read', + resourceMetadataUrlSource: 'www-authenticate', fetchFn: mockFetch }); }); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a0d4e7b6f9..f87b650b38 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1548,6 +1548,128 @@ describe('SSEClientTransport', () => { }); }); + describe('authorization request header isolation', () => { + type RecordedCall = { url: string; init?: RequestInit }; + + const headerOnCall = (call: RecordedCall, name: string): string | null => new Headers(call.init?.headers).get(name); + + const isAuthCall = (call: RecordedCall): boolean => + call.url.includes('/.well-known/') || call.url.endsWith('/register') || call.url.endsWith('/token'); + + const createIsolationAuthProvider = (): Mocked => ({ + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost/callback'] }; + }, + clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }); + + /** + * Serves protected-resource metadata, authorization-server metadata and token + * issuance from one fake origin and accepts data-plane POSTs, recording every + * request so header propagation can be asserted per leg. + */ + const createDispatcherFetch = (calls: RecordedCall[]): Mock => + vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input.toString(); + calls.push({ url, init }); + if (url.includes('/.well-known/oauth-protected-resource')) { + return Response.json({ + resource: 'http://localhost:1234/mcp', + authorization_servers: ['http://localhost:1234'] + }); + } + if (url.includes('/.well-known/oauth-authorization-server')) { + return Response.json({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }); + } + if (url === 'http://localhost:1234/token') { + return Response.json({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600 + }); + } + // Data-plane POST to the message endpoint. + return new Response(null, { status: 200 }); + }); + + /** Exchanges a code (PRM + AS metadata + token) and then sends a data-plane POST. */ + const runAuthAndSend = async (transport: SSEClientTransport): Promise => { + await transport.finishAuth('test-auth-code'); + (transport as unknown as { _endpoint?: URL })._endpoint = new URL('http://localhost:1234/messages'); + await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'req-1' }); + }; + + it('does not apply requestInit headers to authorization requests', async () => { + const calls: RecordedCall[] = []; + transport = new SSEClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: createIsolationAuthProvider(), + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } } + }); + + await runAuthAndSend(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.map(c => c.url)).toEqual( + expect.arrayContaining([ + expect.stringContaining('/.well-known/oauth-protected-resource'), + expect.stringContaining('/.well-known/oauth-authorization-server'), + 'http://localhost:1234/token' + ]) + ); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + } + }); + + it('applies oauthRequestInit headers to authorization requests only', async () => { + const calls: RecordedCall[] = []; + transport = new SSEClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: createIsolationAuthProvider(), + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } }, + oauthRequestInit: { headers: { 'X-Auth-Gateway': 'gateway-value' } } + }); + + await runAuthAndSend(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.length).toBeGreaterThan(0); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Auth-Gateway')).toBe('gateway-value'); + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + expect(headerOnCall(call, 'X-Auth-Gateway')).toBeNull(); + } + }); + }); + describe('minimal AuthProvider (non-OAuth)', () => { let postResponses: number[]; let postCount: number; diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..c790256976 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -7,6 +7,23 @@ import { UnauthorizedError } from '../../src/client/auth'; import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp'; import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; +/** + * fetchWithCorsRetry gates its retry heuristic on the `CORS_IS_POSSIBLE` shim constant. + * Tests run under the Node shim (`false`), where a fetch TypeError propagates as a real + * network error. The header-isolation test that exercises the browser retry leg flips + * the mocked constant to `true` for a single test; the suite's afterEach resets it. + */ +let mockedCorsIsPossible = false; +vi.mock('@modelcontextprotocol/client/_shims', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + get CORS_IS_POSSIBLE() { + return mockedCorsIsPossible; + } + }; +}); + describe('StreamableHTTPClientTransport', () => { let transport: StreamableHTTPClientTransport; let mockAuthProvider: Mocked; @@ -2202,6 +2219,177 @@ describe('StreamableHTTPClientTransport', () => { }); }); + describe('authorization request header isolation', () => { + type RecordedCall = { url: string; init?: RequestInit }; + + afterEach(() => { + mockedCorsIsPossible = false; + }); + + const headerOnCall = (call: RecordedCall, name: string): string | null => new Headers(call.init?.headers).get(name); + + const isAuthCall = (call: RecordedCall): boolean => + call.url.includes('/.well-known/') || call.url.endsWith('/register') || call.url.endsWith('/token'); + + /** + * Serves the full authorization sequence from one fake origin: 401 on the MCP + * GET, protected-resource metadata, authorization-server metadata, client + * registration, token issuance, and 202 on MCP POSTs. Every request is + * recorded so header propagation can be asserted per leg. + */ + const createDispatcherFetch = (calls: RecordedCall[]): Mock => + vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input.toString(); + calls.push({ url, init }); + if (url.includes('/.well-known/oauth-protected-resource')) { + return Response.json({ + resource: 'http://localhost:1234/mcp', + authorization_servers: ['http://localhost:1234'] + }); + } + if (url.includes('/.well-known/oauth-authorization-server')) { + return Response.json({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + registration_endpoint: 'http://localhost:1234/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }); + } + if (url === 'http://localhost:1234/register') { + return Response.json({ + client_id: 'registered-client-id', + redirect_uris: ['http://localhost/callback'] + }); + } + if (url === 'http://localhost:1234/token') { + return Response.json({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600 + }); + } + // MCP endpoint: 401 the GET stream open to trigger the authorization + // flow; accept data-plane POSTs. + if (init?.method === 'GET') { + return new Response(null, { status: 401 }); + } + return new Response(null, { status: 202 }); + }); + + /** + * Drives the full sequence against the dispatcher: 401 → discovery → + * registration → (redirect), then code exchange, then a data-plane POST. + */ + const runFullAuthSequence = async (transport: StreamableHTTPClientTransport): Promise => { + mockAuthProvider.clientInformation.mockReturnValue(undefined); + mockAuthProvider.saveClientInformation = vi.fn(); + + await transport.start(); + await expect( + (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}) + ).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + mockAuthProvider.clientInformation.mockReturnValue({ client_id: 'registered-client-id' }); + await transport.finishAuth('test-auth-code'); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: 'req-1' }); + }; + + it('does not apply requestInit headers to authorization requests', async () => { + const calls: RecordedCall[] = []; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } } + }); + + await runFullAuthSequence(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.map(c => c.url)).toEqual( + expect.arrayContaining([ + expect.stringContaining('/.well-known/oauth-protected-resource'), + expect.stringContaining('/.well-known/oauth-authorization-server'), + 'http://localhost:1234/register', + 'http://localhost:1234/token' + ]) + ); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + } + }); + + it('applies oauthRequestInit headers to authorization requests only', async () => { + const calls: RecordedCall[] = []; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: createDispatcherFetch(calls), + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } }, + oauthRequestInit: { headers: { 'X-Auth-Gateway': 'gateway-value' } } + }); + + await runFullAuthSequence(transport); + + const authCalls = calls.filter(isAuthCall); + expect(authCalls.length).toBeGreaterThan(0); + for (const call of authCalls) { + expect(headerOnCall(call, 'X-Auth-Gateway')).toBe('gateway-value'); + expect(headerOnCall(call, 'X-Api-Key')).toBeNull(); + } + + const dataPlaneCalls = calls.filter(c => !isAuthCall(c)); + expect(dataPlaneCalls.length).toBeGreaterThan(0); + for (const call of dataPlaneCalls) { + expect(headerOnCall(call, 'X-Api-Key')).toBe('transport-secret'); + expect(headerOnCall(call, 'X-Auth-Gateway')).toBeNull(); + } + }); + + it('keeps requestInit headers off the retried simple discovery request', async () => { + // Browser-like runtime: a TypeError from a discovery fetch that carried + // custom headers is retried as a simple request without headers. The + // retry must go through the same un-merged fetch, so connection-level + // requestInit headers must not reappear on it. + mockedCorsIsPossible = true; + + const calls: RecordedCall[] = []; + const dispatcher = createDispatcherFetch(calls); + const fetchImpl = vi.fn(async (input: string | URL, init?: RequestInit): Promise => { + const url = input.toString(); + if (url.includes('/.well-known/oauth-protected-resource') && init?.headers !== undefined) { + calls.push({ url, init }); + throw new TypeError('preflight rejected'); + } + return dispatcher(input, init) as Promise; + }); + + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: fetchImpl, + requestInit: { headers: { 'X-Api-Key': 'transport-secret' } } + }); + + await transport.finishAuth('test-auth-code'); + + const prmCalls = calls.filter(c => c.url.includes('/.well-known/oauth-protected-resource')); + expect(prmCalls).toHaveLength(2); + // First attempt carried the discovery headers (and only those). + expect(headerOnCall(prmCalls[0]!, 'MCP-Protocol-Version')).not.toBeNull(); + expect(headerOnCall(prmCalls[0]!, 'X-Api-Key')).toBeNull(); + // The retried simple request carries no headers at all. + expect(prmCalls[1]!.init?.headers).toBeUndefined(); + }); + }); + describe('SSE retry field handling', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/core-internal/src/exports/public/index.ts b/packages/core-internal/src/exports/public/index.ts index 06f9b829f4..84092a8ddc 100644 --- a/packages/core-internal/src/exports/public/index.ts +++ b/packages/core-internal/src/exports/public/index.ts @@ -39,6 +39,10 @@ export type { // Auth utilities export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/authUtils'; +// The OAuth discovery URL policy (shared/discoveryPolicy.ts) is client-only +// vocabulary: it is exported from @modelcontextprotocol/client, not from here, +// so the server package's surface does not carry client-OAuth-flow machinery. + // Media-type utilities (for transport and framework-adapter authors) export { isJsonContentType } from '../../shared/mediaType'; @@ -62,7 +66,8 @@ export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol'; // stdio message framing utilities (for custom transport authors) export { deserializeMessage, ReadBuffer, serializeMessage, STDIO_DEFAULT_MAX_BUFFER_SIZE } from '../../shared/stdio'; -// Transport types (NOT normalizeHeaders) +// Transport types (NOT normalizeHeaders or the OMIT_BASE_HEADERS sentinel, which +// stay on the internal barrel) export type { FetchLike, Transport, TransportSendOptions } from '../../shared/transport'; export { createFetchWithInit } from '../../shared/transport'; export { InMemoryTransport } from '../../util/inMemory'; diff --git a/packages/core-internal/src/index.ts b/packages/core-internal/src/index.ts index 058de16174..e4f17e7cad 100644 --- a/packages/core-internal/src/index.ts +++ b/packages/core-internal/src/index.ts @@ -4,6 +4,7 @@ export * from './errors/sdkErrors'; export * from './shared/auth'; export * from './shared/authUtils'; export * from './shared/clientCapabilityRequirements'; +export * from './shared/discoveryPolicy'; export * from './shared/envelope'; export * from './shared/inboundClassification'; export * from './shared/inputRequired'; diff --git a/packages/core-internal/src/shared/discoveryPolicy.ts b/packages/core-internal/src/shared/discoveryPolicy.ts new file mode 100644 index 0000000000..053586cb45 --- /dev/null +++ b/packages/core-internal/src/shared/discoveryPolicy.ts @@ -0,0 +1,349 @@ +/** + * URL policy validation for OAuth discovery flows. + * + * Client OAuth discovery adopts and fetches URLs that arrive over the network + * (`WWW-Authenticate` challenges, protected-resource metadata, authorization-server + * metadata). RFC 9728 §7.6 requires the client to verify it trusts an authorization + * server before using it, and RFC 8414 §2 constrains what an issuer identifier may + * look like. {@linkcode assertAllowedDiscoveryUrl} is the mechanical part of that + * verification: a purpose-typed, fail-closed check that runs BEFORE any network I/O + * on every discovery-derived URL. + * + * The default policy is deliberately derivable from the connection itself (no + * app-specific values needed): + * + * 1. `https:` required everywhere; `http:` is permitted only for loopback hosts + * (the RFC 8252 §7.3 exemption behind `assertSecureTokenEndpoint`, here covering + * the full loopback set: `localhost` and its subdomains, 127/8 literals, `[::1]`). + * 2. Locality symmetry ("no descent"): when the step that produced the URL (the + * MCP server, or the authorization server that published an endpoint) is a + * non-local host, discovery may not target loopback or private/link-local IP + * literals. DNS names are deliberately never resolved — an authorization server + * on a private `https:` DNS name (e.g. `https://idp.corp.internal`) stays + * deployable by default, and name-resolution-level controls remain a + * deployer/network-layer concern. + * 3. Protected-resource metadata must be same-origin with the MCP server: RFC 9728 §3 + * derives the well-known URL from the resource identifier itself, so conformant + * metadata is always same-origin. + * + * Every rule that can block a legitimate topology has exactly one named opt-out on + * {@linkcode DiscoveryUrlPolicyOptions}, and every rejection is a typed + * {@linkcode DiscoveryUrlBlockedError} whose message names that opt-out. + */ +import { brandedHasInstance, stampErrorBrands } from '../errors/crossBundleBrand'; + +/** + * What a discovery-flow URL is about to be used for. Purposes that are fetched by + * the SDK (`resource-metadata`, `as-metadata`, `token-endpoint`, + * `registration-endpoint`, `redirect-hop`) are gated immediately before the request; + * purposes that are adopted rather than fetched (`authorization-server`, + * `authorization-endpoint`) are gated at adoption time. + */ +export type DiscoveryUrlPurpose = + /** RFC 9728 protected-resource-metadata GET (`WWW-Authenticate`, caller option, or well-known derivation). */ + | 'resource-metadata' + /** Adoption of an `authorization_servers[]` issuer, including restore from cached discovery state (asserted, not fetched). */ + | 'authorization-server' + /** Each RFC 8414 / OpenID Connect discovery GET derived from the authorization server URL. */ + | 'as-metadata' + /** Browser redirect target (asserted, never fetched by the SDK). */ + | 'authorization-endpoint' + /** Token request POST target. */ + | 'token-endpoint' + /** Dynamic client registration POST target. */ + | 'registration-endpoint' + /** A `Location` target while manually following a redirect from a discovery GET. */ + | 'redirect-hop'; + +/** + * Where a discovery-flow URL came from. The policy itself is source-independent; + * call sites use the source to pick violation handling (e.g. a non-conformant + * `WWW-Authenticate` URL can fall back to the SDK's own well-known derivation, + * while an explicit caller option should fail loudly). + */ +export type DiscoveryUrlSource = + /** + * Relayed from a `WWW-Authenticate` challenge's `resource_metadata` + * parameter. The client transports label the URLs they extract from a + * challenge this way (`resourceMetadataUrlSource: 'www-authenticate'`); + * an explicit URL whose provenance is not declared is labeled `'caller'`. + */ + | 'www-authenticate' + | 'protected-resource-metadata' + | 'authorization-server-metadata' + | 'sdk-derived' + | 'cached-discovery-state' + | 'caller'; + +/** + * The step that produced a discovery-flow URL: the MCP server the flow runs + * against (URLs adopted or derived during discovery — `resource-metadata`, + * `authorization-server`) or the authorization server that published the URL + * (the endpoint and metadata purposes — `as-metadata`, `authorization-endpoint`, + * `token-endpoint`, `registration-endpoint`). Redirect hops carry the original + * producer of the request being followed. + */ +export interface DiscoveryUrlProducer { + /** The producing server's URL — the locality anchor for the no-descent rule. */ + url: URL; + kind: 'mcp-server' | 'authorization-server'; +} + +/** + * Everything known about a discovery-flow URL at the moment it is validated. + * Carried on {@linkcode DiscoveryUrlBlockedError} so callers can report or handle + * rejections precisely. + */ +export interface DiscoveryUrlContext { + purpose: DiscoveryUrlPurpose; + /** The exact URL about to be contacted or adopted. */ + url: URL; + /** + * The step that produced the URL. The no-descent locality rule compares + * `url` against `producer.url`. + */ + producer: DiscoveryUrlProducer; + source: DiscoveryUrlSource; + /** Set while manually following a redirect: the hop being validated. */ + redirectHop?: { + from: URL; + status: number; + originalPurpose: Exclude; + }; +} + +/** + * Options that relax individual rules of the default discovery URL policy. + * All default to off — the policy fails closed. Each option maps to exactly one + * rule, and rejection messages name the option that would have permitted the URL. + */ +export interface DiscoveryUrlPolicyOptions { + /** + * Permit `http:` discovery URLs on non-loopback hosts. By default `http:` is + * accepted only for loopback hosts (`localhost` and its subdomains, 127/8 + * literals, `[::1]`) — the RFC 8252 §7.3 exemption also used by + * `assertSecureTokenEndpoint`. + */ + allowHttpDiscovery?: boolean; + /** + * Honor a protected-resource-metadata URL that is not same-origin with the MCP + * server. RFC 9728 §3 derives the well-known metadata URL from the resource + * identifier itself, so conformant metadata is always same-origin; this option + * exists for gateway/CDN topologies that split the two. + */ + allowCrossOriginResourceMetadata?: boolean; + /** + * Disable the locality-symmetry rule: allow discovery URLs whose host is a + * loopback or private/link-local IP literal even when the producing step + * (`ctx.producer` — the MCP server, or the authorization server that + * published an endpoint) is a non-local host. + */ + allowPrivateAddressTargets?: boolean; +} + +/** + * Thrown by {@linkcode assertAllowedDiscoveryUrl} when a discovery-flow URL fails + * the URL policy. Always thrown before any network I/O and before any discovery + * state is persisted. + */ +export class DiscoveryUrlBlockedError extends Error { + static { + Object.defineProperty(this, 'mcpBrand', { value: 'mcp.DiscoveryUrlBlockedError' }); + } + + static override [Symbol.hasInstance](value: unknown): boolean { + return brandedHasInstance(this, value); + } + + /** + * Brand-based type guard: equivalent to `value instanceof this`, as an + * explicit static predicate (the axios/AWS-SDK `isInstance` style). Reads + * the caller's own brand via `this`, so every branded subclass gets a + * correctly-scoped guard by inheritance. Must be invoked on the class — + * in callback position write `v => DiscoveryUrlBlockedError.isInstance(v)`, not + * `.filter(DiscoveryUrlBlockedError.isInstance)` (detached calls throw rather + * than silently matching nothing). + */ + static isInstance unknown>(this: T, value: unknown): value is InstanceType { + if (typeof this !== 'function') { + throw new TypeError( + 'isInstance must be called on the class (e.g. `DiscoveryUrlBlockedError.isInstance(value)`); for callbacks use `v => DiscoveryUrlBlockedError.isInstance(v)`' + ); + } + return brandedHasInstance(this, value); + } + + constructor( + public readonly context: DiscoveryUrlContext, + public readonly reason: string + ) { + super(`Discovery URL ${context.url.href} not allowed for purpose '${context.purpose}': ${reason}`); + this.name = 'DiscoveryUrlBlockedError'; + stampErrorBrands(this, new.target); + } +} + +/** Hostnames treated as loopback for the `http:` exemption (RFC 8252 §7.3). */ +function isLoopbackHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname.endsWith('.localhost'); +} + +/** Parses a WHATWG-canonicalized dotted-quad IPv4 hostname into its four octets, or undefined for anything else. */ +function parseIpv4(hostname: string): [number, number, number, number] | undefined { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); + if (!match) { + return undefined; + } + const octets = [Number(match[1]), Number(match[2]), Number(match[3]), Number(match[4])]; + if (octets.some(octet => octet > 255)) { + return undefined; + } + return octets as [number, number, number, number]; +} + +function isLocalIpv4(octets: [number, number, number, number]): boolean { + const [a, b] = octets; + return ( + a === 127 || // loopback (127/8) + a === 10 || // private (10/8) + (a === 172 && b >= 16 && b <= 31) || // private (172.16/12) + (a === 192 && b === 168) || // private (192.168/16) + (a === 169 && b === 254) || // link-local (169.254/16) + (a === 0 && octets[1] === 0 && octets[2] === 0 && octets[3] === 0) // unspecified (0.0.0.0) + ); +} + +/** + * Classifies a bracketed, WHATWG-canonicalized IPv6 hostname (e.g. `[::1]`, + * `[fe80::1]`, `[::ffff:7f00:1]`) as loopback/private/link-local. + * + * The WHATWG serializer emits pure hex groups (an IPv4-mapped literal like + * `::ffff:127.0.0.1` arrives here as `[::ffff:7f00:1]`), so the mapped tail is + * reconstructed into octets and re-checked against the IPv4 ranges. + */ +function isLocalIpv6(hostname: string): boolean { + if (!hostname.startsWith('[') || !hostname.endsWith(']')) { + return false; + } + const address = hostname.slice(1, -1); + if (address === '::1' || address === '::') { + return true; // loopback / unspecified + } + if (address.startsWith('::ffff:')) { + // IPv4-mapped (::ffff:0:0/96): check the embedded IPv4 address. + const [highGroup, lowGroup, ...rest] = address.slice('::ffff:'.length).split(':'); + if (highGroup !== undefined && lowGroup !== undefined && rest.length === 0) { + const high = Number.parseInt(highGroup, 16); + const low = Number.parseInt(lowGroup, 16); + if (Number.isFinite(high) && Number.isFinite(low)) { + return isLocalIpv4([(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff]); + } + } + return false; + } + const firstGroup = address.split(':', 1)[0] ?? ''; + if (firstGroup === '') { + return false; // leading `::` compression — first hextet is zero + } + const hextet = Number.parseInt(firstGroup, 16); + if (!Number.isFinite(hextet)) { + return false; + } + return ( + (hextet & 0xfe_00) === 0xfc_00 || // unique-local (fc00::/7) + (hextet & 0xff_c0) === 0xfe_80 // link-local (fe80::/10) + ); +} + +/** + * Whether a hostname is a loopback hostname or a loopback/private/link-local IP + * LITERAL. DNS names (other than `localhost`) are never classified as local — + * the policy deliberately performs no name resolution. + */ +function isLocalHost(hostname: string): boolean { + if (isLoopbackHostname(hostname)) { + return true; + } + const ipv4 = parseIpv4(hostname); + if (ipv4) { + return isLocalIpv4(ipv4); + } + return isLocalIpv6(hostname); +} + +/** Loopback in the narrow RFC 8252 §7.3 sense used for the `http:` exemption. */ +function isLoopbackTarget(hostname: string): boolean { + if (isLoopbackHostname(hostname)) { + return true; + } + const ipv4 = parseIpv4(hostname); + if (ipv4) { + return ipv4[0] === 127; + } + return hostname === '[::1]'; +} + +/** + * Default URL policy for OAuth discovery flows: validates a discovery-derived URL + * against the rules described in the module documentation and throws + * {@linkcode DiscoveryUrlBlockedError} (with the full {@linkcode DiscoveryUrlContext} + * and a reason naming the overriding option) when a rule is violated. Returns + * normally when the URL is allowed. + * + * Exported so custom validation hooks can delegate to the default policy and then + * layer their own trust rules on top. + */ +export function assertAllowedDiscoveryUrl(ctx: DiscoveryUrlContext, opts?: DiscoveryUrlPolicyOptions): void { + const { url, producer } = ctx; + + // Rule 1 (structural): http(s) only, no credentials in the URL, and https + // everywhere except loopback (the RFC 8252 §7.3 exemption behind + // assertSecureTokenEndpoint, applied to the full loopback set). + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new DiscoveryUrlBlockedError( + ctx, + `scheme '${url.protocol}' is not allowed; discovery URLs must use https: (or http: to a loopback host)` + ); + } + if (url.username !== '' || url.password !== '') { + throw new DiscoveryUrlBlockedError(ctx, 'discovery URLs must not carry userinfo credentials'); + } + if (url.protocol === 'http:' && !isLoopbackTarget(url.hostname) && !opts?.allowHttpDiscovery) { + throw new DiscoveryUrlBlockedError( + ctx, + 'http: is only allowed for loopback hosts (RFC 8252 §7.3); use https:, or set allowHttpDiscovery to permit it' + ); + } + + // Rule 2 (locality symmetry): a non-local producer must not steer discovery + // toward loopback or private/link-local IP literals. DNS names are deliberately + // not resolved, so authorization servers on private https DNS names pass. + if (!opts?.allowPrivateAddressTargets && isLocalHost(url.hostname) && !isLocalHost(producer.url.hostname)) { + const producerLabel = producer.kind === 'mcp-server' ? 'MCP server' : 'authorization server'; + throw new DiscoveryUrlBlockedError( + ctx, + `host '${url.hostname}' is a loopback or private address, but the ${producerLabel} '${producer.url.hostname}' is not local; ` + + 'set allowPrivateAddressTargets to permit it' + ); + } + + // Rule 3 (resource metadata origin): RFC 9728 §3 derives the well-known + // metadata URL from the resource identifier itself, so conformant + // protected-resource metadata is always same-origin with the server. + if (ctx.purpose === 'resource-metadata' && url.origin !== producer.url.origin && !opts?.allowCrossOriginResourceMetadata) { + throw new DiscoveryUrlBlockedError( + ctx, + `protected-resource-metadata URL origin '${url.origin}' does not match the MCP server origin '${producer.url.origin}' ` + + '(RFC 9728 §3); set allowCrossOriginResourceMetadata to permit it' + ); + } + + // Rule 4 (issuer syntax): RFC 8414 §2 — an authorization server issuer + // identifier has no query and no fragment components. + if (ctx.purpose === 'authorization-server' && (url.search !== '' || url.hash !== '')) { + throw new DiscoveryUrlBlockedError( + ctx, + 'an authorization server issuer identifier must not have query or fragment components (RFC 8414 §2)' + ); + } +} diff --git a/packages/core-internal/src/shared/transport.ts b/packages/core-internal/src/shared/transport.ts index 226f6ab0bd..f08f6d3ea9 100644 --- a/packages/core-internal/src/shared/transport.ts +++ b/packages/core-internal/src/shared/transport.ts @@ -20,10 +20,31 @@ export function normalizeHeaders(headers: RequestInit['headers'] | undefined): R return { ...(headers as Record) }; } +/** + * Sentinel `headers` value recognized by fetch functions created with + * {@linkcode createFetchWithInit}: a call whose `init.headers` is this exact + * object is sent with no headers at all — the base init's headers are + * suppressed instead of merged underneath or used as a fallback. The sentinel + * is passed through to the wrapped fetch unchanged, so it suppresses base + * headers at every level of a nested-wrapper chain; a terminal fetch treats it + * as an ordinary empty header set. + * + * Intended for callers that follow redirects manually: configured headers are + * scoped to the origin they were sent to, so once a hop leaves that origin the + * caller passes this sentinel to keep the wrapper from re-applying its base + * headers on the cross-origin request. + */ +export const OMIT_BASE_HEADERS: Readonly> = Object.freeze({}); + /** * Creates a fetch function that includes base `RequestInit` options. * This ensures requests inherit settings like credentials, mode, headers, etc. from the base init. * + * Headers merge instead of replacing: per-call headers are laid over the base + * headers, and a call without headers falls back to the base headers. Passing + * the `OMIT_BASE_HEADERS` sentinel (internal barrel only) as the call's + * `headers` opts out of both — the request is sent with no headers. + * * @param baseFetch - The base fetch function to wrap (defaults to global `fetch`) * @param baseInit - The base `RequestInit` to merge with each request * @returns A wrapped fetch function that merges base options with call-specific options @@ -38,8 +59,16 @@ export function createFetchWithInit(baseFetch: FetchLike = fetch, baseInit?: Req const mergedInit: RequestInit = { ...baseInit, ...init, - // Headers need special handling - merge instead of replace - headers: init?.headers ? { ...normalizeHeaders(baseInit.headers), ...normalizeHeaders(init.headers) } : baseInit.headers + // Headers need special handling - merge instead of replace. The + // OMIT_BASE_HEADERS sentinel suppresses the base headers and is passed + // through as-is so nested wrappers honor it too (a terminal fetch treats + // the frozen empty object as an ordinary empty header set). + headers: + init?.headers === OMIT_BASE_HEADERS + ? OMIT_BASE_HEADERS + : init?.headers + ? { ...normalizeHeaders(baseInit.headers), ...normalizeHeaders(init.headers) } + : baseInit.headers }; return baseFetch(url, mergedInit); }; diff --git a/packages/core-internal/test/shared/discoveryPolicy.test.ts b/packages/core-internal/test/shared/discoveryPolicy.test.ts new file mode 100644 index 0000000000..60d04c8d4c --- /dev/null +++ b/packages/core-internal/test/shared/discoveryPolicy.test.ts @@ -0,0 +1,290 @@ +import { + assertAllowedDiscoveryUrl, + DiscoveryUrlBlockedError, + type DiscoveryUrlContext, + type DiscoveryUrlPolicyOptions, + type DiscoveryUrlPurpose +} from '../../src/shared/discoveryPolicy'; + +const ALL_PURPOSES: DiscoveryUrlPurpose[] = [ + 'resource-metadata', + 'authorization-server', + 'as-metadata', + 'authorization-endpoint', + 'token-endpoint', + 'registration-endpoint', + 'redirect-hop' +]; + +const REMOTE_SERVER = new URL('https://mcp.example.com/mcp'); +const LOOPBACK_SERVER = new URL('http://localhost:3000/mcp'); + +const REMOTE_PRODUCER = { url: REMOTE_SERVER, kind: 'mcp-server' } as const; +const LOOPBACK_PRODUCER = { url: LOOPBACK_SERVER, kind: 'mcp-server' } as const; + +function ctx(url: string, overrides: Partial = {}): DiscoveryUrlContext { + return { + purpose: 'as-metadata', + url: new URL(url), + producer: REMOTE_PRODUCER, + source: 'sdk-derived', + ...overrides + }; +} + +function check(url: string, overrides: Partial = {}, opts?: DiscoveryUrlPolicyOptions): void { + assertAllowedDiscoveryUrl(ctx(url, overrides), opts); +} + +describe('assertAllowedDiscoveryUrl', () => { + describe('purpose coverage', () => { + it.each(ALL_PURPOSES)('allows a conformant https URL for purpose %s', purpose => { + // resource-metadata must be same-origin with the server (RFC 9728 §3); + // every other purpose may name a different https origin. + const url = + purpose === 'resource-metadata' + ? 'https://mcp.example.com/.well-known/oauth-protected-resource' + : 'https://as.example.com/.well-known/oauth-authorization-server'; + expect(() => check(url, { purpose })).not.toThrow(); + }); + + it.each(ALL_PURPOSES)('denies a non-loopback http URL for purpose %s', purpose => { + const url = + purpose === 'resource-metadata' + ? 'http://mcp.example.com/.well-known/oauth-protected-resource' + : 'http://as.example.com/authorize'; + expect(() => check(url, { purpose })).toThrow(DiscoveryUrlBlockedError); + }); + }); + + describe('scheme rule (https-or-loopback)', () => { + it('allows https URLs on public hosts', () => { + expect(() => check('https://as.example.com/token')).not.toThrow(); + }); + + it('denies http URLs on public hosts (fail closed)', () => { + expect(() => check('http://as.example.com/token')).toThrow(DiscoveryUrlBlockedError); + }); + + it('allows http URLs on loopback hosts when the server is also local (RFC 8252 §7.3)', () => { + expect(() => check('http://localhost:9000/token', { producer: LOOPBACK_PRODUCER })).not.toThrow(); + expect(() => check('http://127.0.0.1:9000/token', { producer: LOOPBACK_PRODUCER })).not.toThrow(); + expect(() => check('http://[::1]:9000/token', { producer: LOOPBACK_PRODUCER })).not.toThrow(); + }); + + it('denies non-http(s) schemes regardless of options', () => { + const allOn: DiscoveryUrlPolicyOptions = { + allowHttpDiscovery: true, + allowCrossOriginResourceMetadata: true, + allowPrivateAddressTargets: true + }; + expect(() => check('ftp://as.example.com/metadata', {}, allOn)).toThrow(DiscoveryUrlBlockedError); + }); + + it('denies URLs carrying userinfo credentials', () => { + expect(() => check('https://user:secret@as.example.com/token')).toThrow(DiscoveryUrlBlockedError); + expect(() => check('https://user@as.example.com/token')).toThrow(DiscoveryUrlBlockedError); + }); + + it('allowHttpDiscovery permits non-loopback http', () => { + expect(() => check('http://as.example.com/token', {}, { allowHttpDiscovery: true })).not.toThrow(); + }); + }); + + describe('locality symmetry rule', () => { + describe('loopback targets with a remote server are denied (each family and canonical form)', () => { + it.each([ + ['https://127.0.0.1/token', '127.0.0.1'], + ['https://127.8.8.8/token', '127/8 literal'], + ['https://[::1]/token', 'IPv6 loopback'], + ['https://localhost/token', 'loopback hostname'], + ['https://2130706433/token', 'decimal form of 127.0.0.1'], + ['https://0x7f000001/token', 'hex form of 127.0.0.1'], + ['https://0177.0.0.1/token', 'octal form of 127.0.0.1'], + ['https://[::ffff:127.0.0.1]/token', 'IPv4-mapped loopback'] + ])('denies %s (%s)', url => { + // The WHATWG URL parser canonicalizes every encoded form before + // the hostname reaches the policy. + expect(() => check(url)).toThrow(DiscoveryUrlBlockedError); + }); + }); + + it('allows loopback targets when the server itself is loopback', () => { + expect(() => check('https://127.0.0.1/token', { producer: LOOPBACK_PRODUCER })).not.toThrow(); + expect(() => check('http://localhost:9000/token', { producer: LOOPBACK_PRODUCER })).not.toThrow(); + expect(() => check('https://[::1]/token', { producer: LOOPBACK_PRODUCER })).not.toThrow(); + }); + + it('allows loopback targets when the server is a private-range literal', () => { + expect(() => + check('https://127.0.0.1/token', { producer: { url: new URL('https://10.1.2.3/mcp'), kind: 'mcp-server' } }) + ).not.toThrow(); + }); + + describe('private-range literals with a remote server are denied', () => { + it.each([ + ['https://10.0.0.1/token', '10/8'], + ['https://172.16.0.1/token', '172.16/12 lower bound'], + ['https://172.31.255.255/token', '172.16/12 upper bound'], + ['https://192.168.1.1/token', '192.168/16'], + ['https://169.254.169.254/token', '169.254/16 link-local'], + ['https://0.0.0.0/token', 'unspecified IPv4'], + ['https://[::]/token', 'unspecified IPv6'], + ['https://[fc00::1]/token', 'unique-local fc00::/7'], + ['https://[fdab::1]/token', 'unique-local fd00 range'], + ['https://[fe80::1]/token', 'link-local fe80::/10'], + ['https://[::ffff:10.0.0.1]/token', 'IPv4-mapped private'] + ])('denies %s (%s)', url => { + expect(() => check(url)).toThrow(DiscoveryUrlBlockedError); + }); + }); + + it.each([ + ['https://172.32.0.1/token', 'just above 172.16/12'], + ['https://172.15.255.255/token', 'just below 172.16/12'], + ['https://11.0.0.1/token', 'outside 10/8'], + ['https://[fe00::1]/token', 'outside fc00::/7 and fe80::/10'], + ['https://[2001:db8::1]/token', 'global IPv6'] + ])('allows the public literal %s (%s)', url => { + expect(() => check(url)).not.toThrow(); + }); + + it('never blocks DNS names by locality (no name resolution is performed)', () => { + // An authorization server on a private https DNS name stays deployable + // by default; the policy classifies IP literals only. + expect(() => check('https://idp.corp.internal/.well-known/oauth-authorization-server')).not.toThrow(); + expect(() => check('https://intranet.example/token')).not.toThrow(); + }); + + it('allowPrivateAddressTargets disables the rule', () => { + expect(() => check('https://10.0.0.1/token', {}, { allowPrivateAddressTargets: true })).not.toThrow(); + expect(() => check('https://[fe80::1]/token', {}, { allowPrivateAddressTargets: true })).not.toThrow(); + }); + + it('anchors on the producing step and names its kind in the rejection', () => { + expect(() => + check('https://10.0.0.1/token', { + purpose: 'token-endpoint', + source: 'authorization-server-metadata', + producer: { url: new URL('https://as.example.com'), kind: 'authorization-server' } + }) + ).toThrow(/authorization server 'as\.example\.com'/); + expect(() => check('https://10.0.0.1/token')).toThrow(/MCP server 'mcp\.example\.com'/); + }); + + it('allows a loopback endpoint published by a loopback authorization server', () => { + expect(() => + check('http://127.0.0.1:9000/token', { + purpose: 'token-endpoint', + source: 'authorization-server-metadata', + producer: { url: new URL('http://localhost:9000'), kind: 'authorization-server' } + }) + ).not.toThrow(); + }); + }); + + describe('resource-metadata origin rule', () => { + const prm = (url: string): DiscoveryUrlContext => ctx(url, { purpose: 'resource-metadata', source: 'www-authenticate' }); + + it('allows same-origin protected-resource-metadata URLs', () => { + expect(() => assertAllowedDiscoveryUrl(prm('https://mcp.example.com/.well-known/oauth-protected-resource/mcp'))).not.toThrow(); + }); + + it('denies cross-origin protected-resource-metadata URLs (host, scheme, or port mismatch)', () => { + expect(() => assertAllowedDiscoveryUrl(prm('https://other.example.com/.well-known/oauth-protected-resource'))).toThrow( + DiscoveryUrlBlockedError + ); + expect(() => assertAllowedDiscoveryUrl(prm('https://mcp.example.com:8443/.well-known/oauth-protected-resource'))).toThrow( + DiscoveryUrlBlockedError + ); + }); + + it('allowCrossOriginResourceMetadata disables the rule', () => { + expect(() => + assertAllowedDiscoveryUrl(prm('https://other.example.com/.well-known/oauth-protected-resource'), { + allowCrossOriginResourceMetadata: true + }) + ).not.toThrow(); + }); + + it('does not impose the origin rule on other purposes', () => { + expect(() => check('https://as.example.com/.well-known/oauth-authorization-server', { purpose: 'as-metadata' })).not.toThrow(); + }); + }); + + describe('authorization-server issuer syntax rule (RFC 8414 §2)', () => { + const issuer = (url: string): DiscoveryUrlContext => + ctx(url, { purpose: 'authorization-server', source: 'protected-resource-metadata' }); + + it('allows issuer identifiers with a path component', () => { + expect(() => assertAllowedDiscoveryUrl(issuer('https://as.example.com/tenant1'))).not.toThrow(); + }); + + it('denies issuer identifiers with a query component', () => { + expect(() => assertAllowedDiscoveryUrl(issuer('https://as.example.com/?tenant=1'))).toThrow(DiscoveryUrlBlockedError); + }); + + it('denies issuer identifiers with a fragment component', () => { + expect(() => assertAllowedDiscoveryUrl(issuer('https://as.example.com/#section'))).toThrow(DiscoveryUrlBlockedError); + }); + + it('does not impose issuer syntax on other purposes', () => { + expect(() => + check('https://as.example.com/.well-known/oauth-authorization-server?x=1', { purpose: 'as-metadata' }) + ).not.toThrow(); + }); + }); + + describe('DiscoveryUrlBlockedError', () => { + it('carries the full context and a reason naming the overriding option', () => { + const context = ctx('https://10.0.0.1/token', { purpose: 'token-endpoint', source: 'authorization-server-metadata' }); + let caught: unknown; + try { + assertAllowedDiscoveryUrl(context); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DiscoveryUrlBlockedError); + const blocked = caught as DiscoveryUrlBlockedError; + expect(blocked.name).toBe('DiscoveryUrlBlockedError'); + expect(blocked.context).toBe(context); + expect(blocked.context.purpose).toBe('token-endpoint'); + expect(blocked.context.source).toBe('authorization-server-metadata'); + expect(blocked.context.url.href).toBe('https://10.0.0.1/token'); + expect(blocked.context.producer).toBe(REMOTE_PRODUCER); + expect(blocked.reason).toContain('allowPrivateAddressTargets'); + expect(blocked.message).toContain('https://10.0.0.1/token'); + expect(blocked.message).toContain('token-endpoint'); + }); + + it('names allowHttpDiscovery on scheme rejections and allowCrossOriginResourceMetadata on origin rejections', () => { + expect(() => check('http://as.example.com/token')).toThrow(/allowHttpDiscovery/); + expect(() => + check('https://other.example.com/.well-known/oauth-protected-resource', { + purpose: 'resource-metadata', + source: 'caller' + }) + ).toThrow(/allowCrossOriginResourceMetadata/); + }); + + it('carries the redirect hop when validating a Location target', () => { + const context = ctx('https://192.168.1.1/hop', { + purpose: 'redirect-hop', + redirectHop: { + from: new URL('https://as.example.com/.well-known/oauth-authorization-server'), + status: 302, + originalPurpose: 'as-metadata' + } + }); + let caught: unknown; + try { + assertAllowedDiscoveryUrl(context); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DiscoveryUrlBlockedError); + expect((caught as DiscoveryUrlBlockedError).context.redirectHop?.originalPurpose).toBe('as-metadata'); + expect((caught as DiscoveryUrlBlockedError).context.redirectHop?.status).toBe(302); + }); + }); +}); diff --git a/packages/core-internal/test/shared/transport.test.ts b/packages/core-internal/test/shared/transport.test.ts index 2a17ac0643..2e2fed4b79 100644 --- a/packages/core-internal/test/shared/transport.test.ts +++ b/packages/core-internal/test/shared/transport.test.ts @@ -1,4 +1,4 @@ -import { createFetchWithInit, type FetchLike, normalizeHeaders } from '../../src/shared/transport'; +import { createFetchWithInit, type FetchLike, normalizeHeaders, OMIT_BASE_HEADERS } from '../../src/shared/transport'; describe('normalizeHeaders', () => { test('returns empty object for undefined', () => { @@ -161,6 +161,53 @@ describe('createFetchWithInit', () => { ); }); + test('OMIT_BASE_HEADERS suppresses base headers and is passed through unchanged', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + credentials: 'include', + headers: { 'x-base': 'base-value' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'GET', headers: OMIT_BASE_HEADERS }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'GET', + credentials: 'include', + headers: OMIT_BASE_HEADERS + }) + ); + }); + + test('OMIT_BASE_HEADERS suppresses base headers at every level of a nested wrapper chain', async () => { + const mockFetch: FetchLike = vi.fn(); + const inner = createFetchWithInit(mockFetch, { headers: { 'x-inner': 'inner-value' } }); + const outer = createFetchWithInit(inner, { headers: { 'x-outer': 'outer-value' } }); + + await outer('https://example.com', { headers: OMIT_BASE_HEADERS }); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com', expect.objectContaining({ headers: OMIT_BASE_HEADERS })); + }); + + test('a plain empty headers object is not the sentinel: base headers still apply', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + headers: { 'x-base': 'base-value' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { headers: {} }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + headers: { 'x-base': 'base-value' } + }) + ); + }); + test('passes Headers instance through when call init has no headers', async () => { const mockFetch: FetchLike = vi.fn(); const baseHeaders = new Headers({ 'x-base': 'value' }); diff --git a/packages/core-internal/test/types/errorSurfacePins.test.ts b/packages/core-internal/test/types/errorSurfacePins.test.ts index cc01cf4c57..a15de389d1 100644 --- a/packages/core-internal/test/types/errorSurfacePins.test.ts +++ b/packages/core-internal/test/types/errorSurfacePins.test.ts @@ -94,6 +94,7 @@ describe('SdkErrorCode', () => { describe('cross-bundle brand strings', () => { test('pins every core error brand (renaming one severs cross-version matching — must be deliberate)', async () => { const { OAuthError } = await import('../../src/auth/errors'); + const { DiscoveryUrlBlockedError } = await import('../../src/shared/discoveryPolicy'); const { SdkError, SdkHttpError } = await import('../../src/errors/sdkErrors'); const { MissingRequiredClientCapabilityError, @@ -111,6 +112,7 @@ describe('cross-bundle brand strings', () => { expect(brand(SdkError)).toBe('mcp.SdkError'); expect(brand(SdkHttpError)).toBe('mcp.SdkHttpError'); expect(brand(OAuthError)).toBe('mcp.OAuthError'); + expect(brand(DiscoveryUrlBlockedError)).toBe('mcp.DiscoveryUrlBlockedError'); }); }); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 4fda9e661f..c801b650e9 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2321,7 +2321,7 @@ export const REQUIREMENTS: Record = { 'client-auth:token-endpoint:https-guard': { source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', behavior: - "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.", + "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, apply the discovery URL policy to the token endpoint (a remote authorization server naming a loopback endpoint is rejected with DiscoveryUrlBlockedError before the request), and auth()'s refresh branch surfaces both instead of falling through to a fresh /authorize redirect.", transports: ['streamableHttp'], addedInSpecVersion: '2026-07-28', note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 07d052c3b6..12ab79ce69 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -19,6 +19,7 @@ import { createMiddleware, discoverAuthorizationServerMetadata, discoverOAuthProtectedResourceMetadata, + DiscoveryUrlBlockedError, exchangeAuthorization, InsecureTokenEndpointError, InsufficientScopeError, @@ -61,8 +62,8 @@ import { verifies } from '../helpers/verifies'; import type { TestArgs } from '../types'; const ISSUER = 'https://auth.example.com'; -const MCP_URL = 'http://in-process/mcp'; -const RESOURCE = 'http://in-process/mcp'; +const MCP_URL = 'http://localhost/mcp'; +const RESOURCE = 'http://localhost/mcp'; interface MockASConfig { tokenResponses?: Array>; @@ -2549,11 +2550,30 @@ verifies('client-auth:token-endpoint:https-guard', async (_args: TestArgs) => { ).rejects.toThrow(InsecureTokenEndpointError); expect(as.tokenCalls).toHaveLength(0); - // Loopback exemption: the in-process mock AS itself uses an https issuer; cover the exemption - // directly so a future tightening of the guard does not silently break local-dev / test setups. - const loopbackAs = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://127.0.0.1:9001/token' } }); + // Loopback exemption (RFC 8252 §7.3): a loopback authorization server may use an http + // token endpoint; cover the exemption directly so a future tightening of the guard does + // not silently break local-dev / test setups. + const loopbackIssuer = 'http://127.0.0.1:9001'; + const loopbackAs = createMockAuthorizationServer({ asMetadata: { token_endpoint: `${loopbackIssuer}/token` } }); // Route the loopback token URL to the mock. const loopbackFetch = (url: URL | string, init?: RequestInit) => loopbackAs.handleRequest(new Request(url, init)); + await expect( + refreshAuthorization(loopbackIssuer, { + metadata: { + issuer: loopbackIssuer, + authorization_endpoint: `${loopbackIssuer}/authorize`, + token_endpoint: `${loopbackIssuer}/token`, + response_types_supported: ['code'] + }, + clientInformation: { client_id: 'https-guard-client' }, + refreshToken: 'rt', + fetchFn: loopbackFetch + }) + ).resolves.toBeDefined(); + + // Locality rule: a remote authorization server naming a loopback token endpoint is + // rejected before the request is sent — the loopback exemption applies only when the + // authorization server itself is local. await expect( refreshAuthorization(ISSUER, { metadata: { @@ -2566,7 +2586,8 @@ verifies('client-auth:token-endpoint:https-guard', async (_args: TestArgs) => { refreshToken: 'rt', fetchFn: loopbackFetch }) - ).resolves.toBeDefined(); + ).rejects.toThrow(DiscoveryUrlBlockedError); + expect(loopbackAs.tokenCalls).toHaveLength(1); // only the loopback-issuer refresh above reached the endpoint }); verifies('client-auth:refresh:rotation-handling', async (_args: TestArgs) => { diff --git a/test/e2e/scenarios/flow.test.ts b/test/e2e/scenarios/flow.test.ts index 2b75c8fe44..58b539cd80 100644 --- a/test/e2e/scenarios/flow.test.ts +++ b/test/e2e/scenarios/flow.test.ts @@ -508,7 +508,7 @@ verifies('flow:oauth:authorization-code-roundtrip', async (_args: TestArgs) => { // Protected host: serves RFC 9728 resource metadata, otherwise requires the issued bearer token. const handle = hostPerSession(makeServer); - const url = new URL('http://in-process/mcp'); + const url = new URL('http://localhost/mcp'); const serverFetch = async (u: URL | string, init?: RequestInit): Promise => { const req = new Request(u, init); const requestUrl = new URL(req.url); @@ -519,7 +519,7 @@ verifies('flow:oauth:authorization-code-roundtrip', async (_args: TestArgs) => { if (auth !== `Bearer ${ACCESS_TOKEN}`) { return new Response(null, { status: 401, - headers: { 'WWW-Authenticate': `Bearer resource_metadata="http://in-process${PRM_PATH}/mcp"` } + headers: { 'WWW-Authenticate': `Bearer resource_metadata="http://localhost${PRM_PATH}/mcp"` } }); } return handle.handleRequest(req);