Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-prm-resource-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@modelcontextprotocol/client': patch
---

Validate protected resource metadata identity before using its authorization servers or caching it (RFC 9728 §3.3). Challenge and path-specific discovery must identify the requested resource; root well-known fallback must identify the root resource. OAuth authorization, code exchange, and refresh continue to send a valid metadata resource unchanged.

Previously accepted parent-path resources, differing queries, and differing trailing path slashes are now rejected, including when `validateResourceURL` is supplied. That hook still selects the outgoing resource after metadata validation. The shared `checkResourceAllowed` path-containment helper is unchanged.

Path-specific PRM discovery now preserves a non-root path's trailing slash: `/mcp/` is probed at `/.well-known/oauth-protected-resource/mcp/`, rather than `/.well-known/oauth-protected-resource/mcp`. Root discovery and authorization server metadata URL construction are unchanged. Resource validation rejects ambiguous backslash authority boundaries and checks that the parsed URL also matches the expected resource.

Persist the complete discovery state, including the optional `resourceMetadataRootFallback` marker. Cached metadata is revalidated before use; old root-resource caches without discovery provenance are rediscovered.
9 changes: 8 additions & 1 deletion docs/clients/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
shape: how-to
description: 'Sign an end user in from a client you build with the OAuth authorization-code flow.'
---

# Authenticate a user with OAuth

Protecting a server you run → [Require authorization](../serving/authorization.md). Signing a user in from a client → this page. No user present → [Authenticate without a user](./machine-auth.md).
Expand Down Expand Up @@ -149,7 +150,13 @@ A mismatch means the callback came from an authorization server you did not star

## Pin the resource indicator

The SDK binds tokens to your server with the RFC 8707 `resource` parameter: when the server publishes protected resource metadata (RFC 9728), the SDK checks the metadata's `resource` against the server URL and attaches it to the authorization redirect and every token request. Override `validateResourceURL` to force the value — return the URL to send, or `undefined` to omit the parameter.
The SDK binds tokens to your server with the RFC 8707 `resource` parameter. Protected resource metadata (RFC 9728) discovered through a challenge must identify the requested server. For well-known discovery, it must identify the resource used to construct that discovery URL. A root well-known fallback must therefore identify the root resource.

Before using the metadata, the SDK rejects parent paths, different queries, and different trailing path slashes relative to that expected identifier. Valid resource identifiers are sent unchanged on the authorization redirect and every token request.

Override `validateResourceURL` to select the value after metadata identity validation — return the URL to send, or `undefined` to omit the parameter. The hook cannot bypass metadata identity validation.

Persist the complete `OAuthDiscoveryState` returned to `saveDiscoveryState`, including `resourceMetadataRootFallback`, so a valid root fallback can be restored after a redirect. Older root-resource caches without that marker are rediscovered before use.

```ts source="../../examples/guides/clients/oauth.examples.ts#validateResourceURL_pin"
class PinnedResourceProvider extends MyOAuthProvider {
Expand Down
126 changes: 97 additions & 29 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type {
} from '@modelcontextprotocol/core-internal';
import {
brandedHasInstance,
checkResourceAllowed,
fetchWithinOrigin,
LATEST_PROTOCOL_VERSION,
OAuthClientInformationFullSchema,
Expand Down Expand Up @@ -391,9 +390,8 @@ export interface OAuthClientProvider {
dpop?(): DpopSession | undefined | Promise<DpopSession | undefined>;

/**
* If defined, overrides the selection and validation of the
* RFC 8707 Resource Indicator. If left undefined, default
* validation behavior will be used.
* If defined, overrides selection of the RFC 8707 Resource Indicator
* after protected resource metadata passes RFC 9728 identity validation.
*
* Implementations must verify the returned resource matches the MCP server.
*/
Expand Down Expand Up @@ -1174,6 +1172,7 @@ async function authInternal(
let authorizationServerUrl: string | URL;
let metadata: AuthorizationServerMetadata | undefined;
let freshDiscoveryState: OAuthDiscoveryState | undefined;
let resourceMetadataRootFallback = false;

// If resourceMetadataUrl is not provided, try to load it from cached state
// This handles browser redirects where the URL was saved before navigation
Expand All @@ -1182,41 +1181,57 @@ async function authInternal(
effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl);
}

if (cachedState?.authorizationServerUrl) {
// Older caches did not distinguish a root fallback from an invalid path-level response.
const rediscoverRootMetadata =
cachedState?.resourceMetadata !== undefined &&
cachedState.resourceMetadataRootFallback === undefined &&
effectiveResourceMetadataUrl === undefined &&
resourceMatches(new URL('/', serverUrl), cachedState.resourceMetadata.resource) &&
!resourceMatches(resourceUrlFromServerUrl(serverUrl), cachedState.resourceMetadata.resource);

if (cachedState?.authorizationServerUrl && !rediscoverRootMetadata) {
// Restore discovery state from cache
authorizationServerUrl = cachedState.authorizationServerUrl;
resourceMetadata = cachedState.resourceMetadata;
metadata =
cachedState.authorizationServerMetadata ??
(await discoverAuthorizationServerMetadata(authorizationServerUrl, {
fetchFn,
skipIssuerValidation: skipIssuerMetadataValidation
}));
resourceMetadataRootFallback = cachedState.resourceMetadataRootFallback === true && effectiveResourceMetadataUrl === undefined;
if (resourceMetadata) {
validateResourceMetadata(resourceMetadata, resourceMetadataRootFallback ? new URL('/', serverUrl) : serverUrl);
}

// If resource metadata wasn't cached, try to fetch it for selectResourceURL
if (!resourceMetadata) {
try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
const discovered = await discoverProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: effectiveResourceMetadataUrl },
fetchFn
);
resourceMetadata = discovered.metadata;
resourceMetadataRootFallback = discovered.rootFallback;
} catch (error) {
// Network failures (DNS, connection refused) surface as TypeError — propagate
// those rather than masking a transient reachability problem.
if (error instanceof TypeError) {
if (error instanceof TypeError || error instanceof ProtectedResourceMismatchError) {
throw error;
}
// RFC 9728 not available — selectResourceURL will handle undefined
}
}

metadata =
cachedState.authorizationServerMetadata ??
(await discoverAuthorizationServerMetadata(authorizationServerUrl, {
fetchFn,
skipIssuerValidation: skipIssuerMetadataValidation
}));

// Re-save if we enriched the cached state with missing metadata
if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) {
await provider.saveDiscoveryState?.({
authorizationServerUrl: String(authorizationServerUrl),
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
...(resourceMetadataRootFallback && { resourceMetadataRootFallback: true }),
authorizationServerMetadata: metadata
});
}
Expand All @@ -1230,6 +1245,7 @@ async function authInternal(
authorizationServerUrl = serverInfo.authorizationServerUrl;
metadata = serverInfo.authorizationServerMetadata;
resourceMetadata = serverInfo.resourceMetadata;
resourceMetadataRootFallback = serverInfo.resourceMetadataRootFallback === true;

// Captured now, persisted only after the SEP-2352 callback-leg gate below — so a
// gate throw cannot leave a freshly resolved (potentially PRM-poisoned) AS recorded
Expand All @@ -1241,6 +1257,7 @@ async function authInternal(
authorizationServerUrl: String(authorizationServerUrl),
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
...(resourceMetadataRootFallback && { resourceMetadataRootFallback: true }),
authorizationServerMetadata: metadata
};
}
Expand Down Expand Up @@ -1291,7 +1308,7 @@ async function authInternal(
// `URL`, and `URL.href` appends "/" to a pathless indicator such as `https://example.com`,
// which exact-match authorization servers reject (#1968). A URL returned by the
// provider's own `validateResourceURL` is used as returned.
const selectedResource = await selectResourceURL(serverUrl, provider, resourceMetadata);
const selectedResource = await selectValidatedResourceURL(serverUrl, provider, resourceMetadata);
const resource: string | URL | undefined =
selectedResource && resourceMetadata && !provider.validateResourceURL ? resourceMetadata.resource : selectedResource;

Expand Down Expand Up @@ -1516,8 +1533,8 @@ export function isHttpsUrl(value?: string): boolean {
/**
* Selects the RFC 8707 resource indicator for an MCP server: the provider's
* {@linkcode OAuthClientProvider.validateResourceURL | validateResourceURL} result when
* implemented, otherwise the protected resource metadata's `resource` (checked against the
* server URL with `checkResourceAllowed`), or `undefined` when there is no metadata.
* implemented, otherwise the protected resource metadata's `resource`, or `undefined` when
* there is no metadata. Metadata must identify the supplied server URL; parent paths are rejected.
*
* The result is a parsed `URL`, so a pathless indicator such as `https://example.com` has
* the `href` `https://example.com/`. {@linkcode auth} therefore sends the metadata string
Expand All @@ -1528,6 +1545,15 @@ export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
resourceMetadata?: OAuthProtectedResourceMetadata
): Promise<URL | undefined> {
if (resourceMetadata) validateResourceMetadata(resourceMetadata, serverUrl);
return selectValidatedResourceURL(serverUrl, provider, resourceMetadata);
}

async function selectValidatedResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
resourceMetadata?: OAuthProtectedResourceMetadata
): Promise<URL | undefined> {
const defaultResource = resourceUrlFromServerUrl(serverUrl);

Expand All @@ -1541,14 +1567,33 @@ export async function selectResourceURL(
return undefined;
}

// Validate that the metadata's resource is compatible with our request
if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) {
throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`);
}
// Prefer the resource from metadata since it's what the server is telling us to request
return new URL(resourceMetadata.resource);
}

class ProtectedResourceMismatchError extends Error {
override name = 'ProtectedResourceMismatchError';
}

function resourceMatches(expected: URL, resource: string): boolean {
// Normalize the authority and empty root path, preserving path spelling, query and fragment.
const match = /^https?:\/\/[^/?#\\\s]+(.*)$/i.exec(resource);
if (!match || match[0] !== resource) return false;
const actual = new URL(resource);
if (actual.username || actual.password || actual.href !== expected.href) return false;
const suffix = match[1]!;
return `${actual.origin}${suffix.startsWith('/') ? suffix : `/${suffix}`}` === expected.href;
}

function validateResourceMetadata(metadata: OAuthProtectedResourceMetadata, expectedResource: string | URL): void {
const expected = resourceUrlFromServerUrl(expectedResource);
if (!resourceMatches(expected, metadata.resource)) {
throw new ProtectedResourceMismatchError(
`Protected resource ${JSON.stringify(metadata.resource)} does not match expected ${JSON.stringify(expected.href)} (RFC 9728 §3.3)`
);
}
}

/** Auth-scheme challenge tokens {@linkcode extractWWWAuthenticateParams} recognizes. */
const RECOGNIZED_CHALLENGE_SCHEMES = new Set(['bearer', 'dpop']);

Expand Down Expand Up @@ -1667,7 +1712,16 @@ export async function discoverOAuthProtectedResourceMetadata(
opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL },
fetchFn: FetchLike = fetch
): Promise<OAuthProtectedResourceMetadata> {
const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, {
const discovered = await discoverProtectedResourceMetadata(serverUrl, opts, fetchFn);
return discovered.metadata;
}

async function discoverProtectedResourceMetadata(
serverUrl: string | URL,
opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL },
fetchFn: FetchLike = fetch
): Promise<{ metadata: OAuthProtectedResourceMetadata; rootFallback: boolean }> {
const { response, rootFallback } = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, {
protocolVersion: opts?.protocolVersion,
metadataUrl: opts?.resourceMetadataUrl
});
Expand All @@ -1681,7 +1735,9 @@ export async function discoverOAuthProtectedResourceMetadata(
await response.text?.().catch(() => {});
throw new Error(`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`);
}
return OAuthProtectedResourceMetadataSchema.parse(await response.json());
const metadata = OAuthProtectedResourceMetadataSchema.parse(await response.json());
validateResourceMetadata(metadata, rootFallback ? new URL('/', serverUrl) : serverUrl);
return { metadata, rootFallback };
}

/**
Expand Down Expand Up @@ -1771,7 +1827,7 @@ async function discoverMetadataWithFallback(
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
fetchFn: FetchLike,
opts?: { protocolVersion?: string; metadataUrl?: string | URL; metadataServerUrl?: string | URL }
): Promise<Response | undefined> {
): Promise<{ response: Response | undefined; rootFallback: boolean }> {
const issuer = new URL(serverUrl);
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;

Expand All @@ -1780,20 +1836,25 @@ async function discoverMetadataWithFallback(
url = new URL(opts.metadataUrl);
} else {
// Try path-aware discovery first
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
const wellKnownPath =
wellKnownType === 'oauth-protected-resource'
? `/.well-known/${wellKnownType}${issuer.pathname === '/' ? '' : issuer.pathname}`
: buildWellKnownPath(wellKnownType, issuer.pathname);
url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer);
url.search = issuer.search;
}

let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
let rootFallback = false;

// If path-aware discovery fails (4xx, 502 or a redirect not followed) 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);
rootFallback = true;
}

return response;
return { response, rootFallback };
}

/**
Expand Down Expand Up @@ -1826,7 +1887,7 @@ export async function discoverOAuthMetadata(
}
protocolVersion ??= LATEST_PROTOCOL_VERSION;

const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, {
const { response } = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, {
protocolVersion,
metadataServerUrl: authorizationServerUrl
});
Expand Down Expand Up @@ -2023,6 +2084,9 @@ export interface OAuthServerInfo {
* or `undefined` if the server does not support it.
*/
resourceMetadata?: OAuthProtectedResourceMetadata;

/** True when the metadata was validated against the root well-known fallback; persist with discovery state. */
resourceMetadataRootFallback?: boolean;
}

/**
Expand Down Expand Up @@ -2059,21 +2123,24 @@ export async function discoverOAuthServerInfo(
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | undefined;
let resourceMetadataRootFallback = false;

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
const discovered = await discoverProtectedResourceMetadata(
serverUrl,
{ resourceMetadataUrl: opts?.resourceMetadataUrl },
opts?.fetchFn
);
resourceMetadata = discovered.metadata;
resourceMetadataRootFallback = discovered.rootFallback;
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) {
if (error instanceof TypeError || error instanceof ProtectedResourceMismatchError) {
throw error;
}
// RFC 9728 not supported -- fall back to treating the server URL as the authorization server
Expand All @@ -2093,7 +2160,8 @@ export async function discoverOAuthServerInfo(
return {
authorizationServerUrl,
authorizationServerMetadata,
resourceMetadata
resourceMetadata,
...(resourceMetadataRootFallback && { resourceMetadataRootFallback: true })
};
}

Expand Down
Loading
Loading