diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 1a021be18..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( @@ -1439,6 +1464,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,