Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
5 changes: 5 additions & 0 deletions .changeset/sep-2352-as-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Implement SEP-2352 authorization server binding: when OAuth discovery shows the authorization server has changed since client credentials were recorded, `auth()` now invalidates the stale client registration and tokens (`invalidateCredentials('client')` / `('tokens')`) and re-registers with the new authorization server. CIMD (HTTPS URL) client IDs are portable across authorization servers, so they are exempt from client re-registration, but their tokens are still invalidated when the authorization server changes. Provider implementations should persist client credentials keyed by the authorization server's `issuer` identifier.
177 changes: 156 additions & 21 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@
* Loads information about this OAuth client, as registered already with the
* server, or returns `undefined` if the client is not registered with the
* server.
*
* Per SEP-2352 (authorization server binding), implementations that persist
* client credentials SHOULD key them by the authorization server's `issuer`
* identifier, and SHOULD NOT return credentials that were issued by a
* different authorization server. CIMD (HTTPS URL) client IDs are exempt:
* they are portable across authorization servers.
*/
clientInformation(): OAuthClientInformationMixed | undefined | Promise<OAuthClientInformationMixed | undefined>;

Expand All @@ -177,6 +183,11 @@
*
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*
* Per SEP-2352 (authorization server binding), implementations SHOULD persist
* client credentials keyed by the authorization server's `issuer` identifier,
* so credentials registered with one authorization server are never reused
* with another.
*/
saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise<void>;

Expand Down Expand Up @@ -616,22 +627,29 @@
): Promise<AuthResult> {
// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();
const savedAuthorizationServerUrl = await provider.authorizationServerUrl?.();

let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | URL;
let metadata: AuthorizationServerMetadata | undefined;
let discoveryStateToSave: OAuthDiscoveryState | undefined;
let authorizationServerSource: OAuthServerInfo['authorizationServerSource'];
let reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = false;
let currentAuthorizationServerWasPrmValidated = false;

// 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.
let effectiveResourceMetadataUrl = resourceMetadataUrl;
if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) {
effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl);
}
const shouldRefreshCachedDiscovery = cachedState?.authorizationServerUrl !== undefined && resourceMetadataUrl !== undefined;

if (cachedState?.authorizationServerUrl) {
if (cachedState?.authorizationServerUrl && !shouldRefreshCachedDiscovery) {
Comment thread
claude[bot] marked this conversation as resolved.
// Restore discovery state from cache
authorizationServerUrl = cachedState.authorizationServerUrl;
resourceMetadata = cachedState.resourceMetadata;
authorizationServerSource = cachedState.authorizationServerSource;
metadata =
cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn }));

Expand All @@ -655,34 +673,123 @@

// Re-save if we enriched the cached state with missing metadata
if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) {
await provider.saveDiscoveryState?.({
discoveryStateToSave = {
authorizationServerUrl: String(authorizationServerUrl),
authorizationServerSource,
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
});
};
}
} else {
// Full discovery via RFC 9728
const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn });
authorizationServerUrl = serverInfo.authorizationServerUrl;
metadata = serverInfo.authorizationServerMetadata;
resourceMetadata = serverInfo.resourceMetadata;

// Persist discovery state for future use
// TODO: resourceMetadataUrl is only populated when explicitly provided via options
// or loaded from cached state. The URL derived internally by
// discoverOAuthProtectedResourceMetadata() is not captured back here.
await provider.saveDiscoveryState?.({
authorizationServerUrl: String(authorizationServerUrl),
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
});
const discoveryWasUnvalidated = serverInfo.authorizationServerSource !== 'protected-resource-metadata';
const fallbackAuthorizationServerUrl = cachedState?.authorizationServerUrl ?? savedAuthorizationServerUrl;

if (discoveryWasUnvalidated && fallbackAuthorizationServerUrl) {
authorizationServerUrl = fallbackAuthorizationServerUrl;
resourceMetadata = serverInfo.resourceMetadata ?? cachedState?.resourceMetadata;
authorizationServerSource = cachedState?.authorizationServerSource;
reusedSavedAuthorizationServerAfterUnvalidatedDiscovery = cachedState?.authorizationServerUrl === undefined;
const fallbackMatchesDiscoveredAuthorizationServer =
normalizeAuthorizationServerIdentity(String(fallbackAuthorizationServerUrl)) ===
normalizeAuthorizationServerIdentity(String(serverInfo.authorizationServerUrl));
metadata =
cachedState?.authorizationServerMetadata ??
(fallbackMatchesDiscoveredAuthorizationServer ? serverInfo.authorizationServerMetadata : undefined) ??
(await discoverAuthorizationServerMetadata(fallbackAuthorizationServerUrl, { fetchFn }));

if (cachedState?.authorizationServerUrl && metadata !== cachedState.authorizationServerMetadata) {
discoveryStateToSave = {
authorizationServerUrl: String(authorizationServerUrl),
authorizationServerSource,
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
};
}

Check warning on line 711 in packages/client/src/client/auth.ts

View check run for this annotation

Claude / Claude Code Review

Fallback branch re-save condition ignores refreshed resource metadata

In the unvalidated-fallback branch, the discovery state is only re-persisted when the AS metadata changed (`metadata !== cachedState.authorizationServerMetadata`), unlike the cached-restore branch above (line 675) which also re-saves when `resourceMetadata !== cachedState.resourceMetadata`. When the PRM document is fetched successfully but legitimately omits `authorization_servers`, the fresh resource metadata (e.g. updated `scopes_supported` / `resource`) is used for the current call but never
Comment thread
mattzcarey marked this conversation as resolved.
Outdated
} else {
authorizationServerUrl = serverInfo.authorizationServerUrl;
const discoveredAuthorizationServerMatchesCached =
cachedState?.authorizationServerUrl !== undefined &&
normalizeAuthorizationServerIdentity(String(serverInfo.authorizationServerUrl)) ===
normalizeAuthorizationServerIdentity(cachedState.authorizationServerUrl);
metadata =
serverInfo.authorizationServerMetadata ??
(discoveredAuthorizationServerMatchesCached ? cachedState?.authorizationServerMetadata : undefined);
resourceMetadata = serverInfo.resourceMetadata;
authorizationServerSource = serverInfo.authorizationServerSource;
currentAuthorizationServerWasPrmValidated = authorizationServerSource === 'protected-resource-metadata';

// Persist discovery state for future use
// TODO: resourceMetadataUrl is only populated when explicitly provided via options
// or loaded from cached state. The URL derived internally by
// discoverOAuthProtectedResourceMetadata() is not captured back here.
Comment thread
claude[bot] marked this conversation as resolved.
if (authorizationServerSource === 'protected-resource-metadata' || !fallbackAuthorizationServerUrl) {
discoveryStateToSave = {
authorizationServerUrl: String(authorizationServerUrl),
authorizationServerSource,
Comment thread
claude[bot] marked this conversation as resolved.
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
};
}
}
}

// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider)
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
// SEP-2352: Authorization server binding. Client credentials are bound to the
// authorization server that issued them; when discovery shows the authorization
// server has changed (e.g., via updated protected resource metadata), stale client
// credentials and tokens MUST NOT be reused and the client MUST re-register.
//
// Canonical comparison key: the validated authorization server metadata `issuer`
// (the identifier SEP-2352 specifies). The authorization server URL is only
// comparable when it came from protected resource metadata. Legacy fallback to
// the MCP server origin is not authoritative enough to invalidate credentials.
const previousAuthServerIdentities = [
cachedState?.authorizationServerMetadata?.issuer,
cachedState?.authorizationServerUrl,
savedAuthorizationServerUrl
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map(value => normalizeAuthorizationServerIdentity(value));
const currentAuthServerIdentities = (
currentAuthorizationServerWasPrmValidated ? [metadata?.issuer, String(authorizationServerUrl)] : []
)
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map(value => normalizeAuthorizationServerIdentity(value));
const authorizationServerChanged =
previousAuthServerIdentities.length > 0 &&
currentAuthServerIdentities.length > 0 &&
!currentAuthServerIdentities.some(identity => previousAuthServerIdentities.includes(identity));

if (authorizationServerChanged) {
await provider.invalidateCredentials?.('tokens');

const staleClientInformation = await Promise.resolve(provider.clientInformation());
// CIMD (URL-based) client IDs are portable across authorization servers
// (SEP-991/SEP-2352) — no client invalidation or re-registration is needed.
// During code exchange, keep the client registered by the redirect flow
// that produced this authorization code.
if (staleClientInformation && !isHttpsUrl(staleClientInformation.client_id) && authorizationCode === undefined) {
await provider.invalidateCredentials?.('client');
}
Comment thread
mattzcarey marked this conversation as resolved.
}
Comment thread
mattzcarey marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.

if (discoveryStateToSave) {
await provider.saveDiscoveryState?.(discoveryStateToSave);
}

Comment thread
claude[bot] marked this conversation as resolved.
// Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider).
// Do not replace an existing AS with legacy fallback; fallback is not authoritative
// enough to overwrite a URL discovered from protected resource metadata.
if (
!reusedSavedAuthorizationServerAfterUnvalidatedDiscovery &&
(authorizationServerSource !== 'legacy-fallback' || previousAuthServerIdentities.length === 0)
) {
await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl));
}

const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata);

Expand Down Expand Up @@ -840,6 +947,24 @@
}
}

/**
* SEP-2352: Normalizes an authorization server identity (issuer identifier or
* authorization server URL) for comparison, so that textual variations of the
* same URL (e.g. a missing trailing slash on an issuer URL) do not
* register as an authorization server change.
*/
function normalizeAuthorizationServerIdentity(value: string): string {
try {
const url = new URL(value);
if (url.pathname !== '/') {
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
}
return url.href;
} catch {
return value;
}
}

Comment thread
claude[bot] marked this conversation as resolved.
export async function selectResourceURL(
serverUrl: string | URL,
provider: OAuthClientProvider,
Expand Down Expand Up @@ -1292,6 +1417,12 @@
* or `undefined` if the server does not support it.
*/
resourceMetadata?: OAuthProtectedResourceMetadata;

/**
* Where the authorization server URL came from. Discovery calls set this
* field; it is optional so older persisted discovery state remains valid.
*/
authorizationServerSource?: 'protected-resource-metadata' | 'legacy-fallback';
}

/**
Expand Down Expand Up @@ -1323,6 +1454,7 @@
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | undefined;
let authorizationServerSource: OAuthServerInfo['authorizationServerSource'];

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
Expand All @@ -1332,6 +1464,7 @@
);
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
authorizationServerUrl = resourceMetadata.authorization_servers[0];
authorizationServerSource = 'protected-resource-metadata';
}
} catch (error) {
// Network failures (DNS, connection refused) surface as TypeError from fetch. Those are
Expand All @@ -1347,12 +1480,14 @@
// fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server
if (!authorizationServerUrl) {
authorizationServerUrl = String(new URL('/', serverUrl));
authorizationServerSource = 'legacy-fallback';
}

const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn });

return {
authorizationServerUrl,
authorizationServerSource,
authorizationServerMetadata,
resourceMetadata
};
Expand Down
Loading
Loading