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
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.
180 changes: 159 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 @@ export interface OAuthClientProvider {
* 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 @@ export interface OAuthClientProvider {
*
* 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 @@ async function authInternal(
): 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,126 @@ async function authInternal(

// 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 || resourceMetadata !== cachedState.resourceMetadata)
) {
discoveryStateToSave = {
authorizationServerUrl: String(authorizationServerUrl),
authorizationServerSource,
resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(),
resourceMetadata,
authorizationServerMetadata: metadata
};
}
} 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 +950,24 @@ export function isHttpsUrl(value?: string): boolean {
}
}

/**
* 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 +1420,12 @@ export interface OAuthServerInfo {
* 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 +1457,7 @@ export async function discoverOAuthServerInfo(
): Promise<OAuthServerInfo> {
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
let authorizationServerUrl: string | undefined;
let authorizationServerSource: OAuthServerInfo['authorizationServerSource'];

try {
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
Expand All @@ -1332,6 +1467,7 @@ export async function discoverOAuthServerInfo(
);
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 +1483,14 @@ export async function discoverOAuthServerInfo(
// 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