Skip to content
Open
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
57 changes: 43 additions & 14 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthClientProvider, Promise<AuthResult>>();

/**
* Function type for adding client authentication to token requests.
*/
Expand Down Expand Up @@ -547,23 +555,40 @@ export async function auth(
fetchFn?: FetchLike;
}
): Promise<AuthResult> {
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(
Expand Down Expand Up @@ -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,
Expand Down
Loading