From fa780fa85be93718f16a5f710a64bfb2ce7ec89b Mon Sep 17 00:00:00 2001 From: lanxevo3 Date: Thu, 26 Mar 2026 16:55:19 -0500 Subject: [PATCH 1/2] fix(auth): enforce form-urlencoded Content-Type for token endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth 2.1 §3.2 requires token endpoint requests to use application/x-www-form-urlencoded regardless of grant type. Add an explicit header.set() call immediately before the fetch in executeTokenRequest() to prevent any addClientAuthentication implementation from accidentally overriding the Content-Type. Fixes modelcontextprotocol/inspector#1160 --- packages/client/src/client/auth.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 1a021be18..0391e35e9 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1439,6 +1439,10 @@ export async function executeTokenRequest( applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams); } + // Ensure Content-Type is always form-urlencoded for the token endpoint (OAuth 2.1 §3.2). + // Some addClientAuthentication implementations may have inadvertently set a different value. + headers.set('Content-Type', 'application/x-www-form-urlencoded'); + const response = await (fetchFn ?? fetch)(tokenUrl, { method: 'POST', headers, From 45c34516c7dfa8891fad119f9065cfb73c84f4ec Mon Sep 17 00:00:00 2001 From: lanxevo3 Date: Sat, 28 Mar 2026 11:17:50 -0500 Subject: [PATCH 2/2] fix(auth): deduplicate concurrent auth() calls to prevent refresh token race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two requests receive a 401 simultaneously, both call auth() which each call refreshAuthorization() with the same rotating refresh token. OAuth servers that implement RFC 6819 §5.2.2.3 replay detection revoke the entire token family on seeing a reused refresh token — permanently breaking the session until manual re-authorization. The fix adds a pending-auth Map that tracks in-flight auth Promises per provider. Concurrent callers receive the same Promise and wait for one refresh instead of each triggering their own, which is safe for both rotating and non-rotating token schemes. Fixes #1760. --- packages/client/src/client/auth.ts | 53 ++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 0391e35e9..a0ad7f415 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -25,6 +25,14 @@ import { } from '@modelcontextprotocol/core'; import pkceChallenge from 'pkce-challenge'; +/** + * Deduplicates concurrent auth() calls for the same provider. + * When multiple requests hit a 401 simultaneously, only one auth refresh + * runs — all callers share the same Promise and receive the same result. + * This prevents race conditions with rotating refresh tokens (RFC 6819 §5.2.2.3). + */ +const pendingAuth = new Map>(); + /** * Function type for adding client authentication to token requests. */ @@ -547,23 +555,40 @@ export async function auth( fetchFn?: FetchLike; } ): Promise { - try { - return await authInternal(provider, options); - } catch (error) { - // Handle recoverable error types by invalidating credentials and retrying - if (error instanceof OAuthError) { - if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) { - await provider.invalidateCredentials?.('all'); - return await authInternal(provider, options); - } else if (error.code === OAuthErrorCode.InvalidGrant) { - await provider.invalidateCredentials?.('tokens'); - return await authInternal(provider, options); + // Deduplicate concurrent auth calls for the same provider. + // This prevents a race condition where two simultaneous 401 responses both + // trigger auth(), both call refreshAuthorization() with the same rotating + // refresh token, and the second one gets InvalidGrant because the server + // already consumed the token (RFC 6819 §5.2.2.3 replay detection). + const pending = pendingAuth.get(provider); + if (pending !== undefined) { + return pending; + } + + const promise = (async () => { + try { + return await authInternal(provider, options); + } catch (error) { + // Handle recoverable error types by invalidating credentials and retrying + if (error instanceof OAuthError) { + if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) { + await provider.invalidateCredentials?.('all'); + return await authInternal(provider, options); + } else if (error.code === OAuthErrorCode.InvalidGrant) { + await provider.invalidateCredentials?.('tokens'); + return await authInternal(provider, options); + } } + + // Throw otherwise + throw error; + } finally { + pendingAuth.delete(provider); } + })(); - // Throw otherwise - throw error; - } + pendingAuth.set(provider, promise); + return promise; } async function authInternal(