From 59aa6b109a8538cddc2a043fdb048bf877f2d7cb Mon Sep 17 00:00:00 2001 From: Adam Boudjemaa Date: Sun, 29 Mar 2026 11:51:58 +0200 Subject: [PATCH 1/2] fix(auth): deduplicate concurrent token refresh requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple requests detect a 401 simultaneously, they each call auth() which races to refresh using the same refresh token. With OAuth servers that use rotating refresh tokens (e.g. Atlassian, Asana), this triggers RFC 6819 §5.2.2.3 replay detection — the second refresh invalidates the first's new token, revoking the entire token family. Add a Map-based singleton guard that stores the in-flight auth promise keyed by provider. Concurrent callers await the existing promise instead of starting a new refresh. The promise is cleared on resolve/reject so future refreshes proceed normally. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/client/src/client/auth.ts | 41 +++ packages/client/test/client/auth.test.ts | 344 +++++++++++++++++++++++ 2 files changed, 385 insertions(+) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 7f7f44019..3865714a2 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -388,11 +388,26 @@ export async function parseErrorResponse(input: Response | string): Promise>(); + /** * Orchestrates the full auth flow with a server. * * This can be used as a single entry point for all authorization functionality, * instead of linking together the other lower-level functions in this module. + * + * Concurrent calls for the same provider are deduplicated: only one auth flow + * runs at a time, and all concurrent callers receive the same result. This is + * critical for OAuth servers that use rotating refresh tokens, where concurrent + * refresh requests would invalidate each other. */ export async function auth( provider: OAuthClientProvider, @@ -403,6 +418,32 @@ export async function auth( resourceMetadataUrl?: URL; fetchFn?: FetchLike; } +): Promise { + // If there's already an in-flight auth for this provider, reuse it + const pending = pendingAuthRequests.get(provider); + if (pending) { + return await pending; + } + + const authPromise = authWithErrorHandling(provider, options); + pendingAuthRequests.set(provider, authPromise); + + try { + return await authPromise; + } finally { + pendingAuthRequests.delete(provider); + } +} + +async function authWithErrorHandling( + provider: OAuthClientProvider, + options: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } ): Promise { try { return await authInternal(provider, options); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 9d8f5cf6b..7202d4779 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3668,4 +3668,348 @@ describe('OAuth Authorization', () => { }); }); }); + + describe('concurrent auth deduplication', () => { + it('deduplicates concurrent token refresh requests for the same provider', async () => { + let tokenRequestCount = 0; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString.includes('/token')) { + tokenRequestCount++; + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const concurrentProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client-id', + client_secret: 'client-secret' + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'expired-access-token', + token_type: 'bearer', + refresh_token: 'current-refresh-token' + }), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() + }; + + // Fire 3 concurrent auth calls for the same provider + const results = await Promise.all([ + auth(concurrentProvider, { serverUrl: 'https://api.example.com/mcp-server' }), + auth(concurrentProvider, { serverUrl: 'https://api.example.com/mcp-server' }), + auth(concurrentProvider, { serverUrl: 'https://api.example.com/mcp-server' }) + ]); + + // All should succeed + expect(results).toEqual(['AUTHORIZED', 'AUTHORIZED', 'AUTHORIZED']); + + // Only ONE token refresh request should have been made + expect(tokenRequestCount).toBe(1); + }); + + it('allows new auth after previous one completes', async () => { + let tokenRequestCount = 0; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString.includes('/token')) { + tokenRequestCount++; + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: `access-token-${tokenRequestCount}`, + token_type: 'bearer', + expires_in: 3600, + refresh_token: `refresh-token-${tokenRequestCount}` + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const sequentialProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client-id', + client_secret: 'client-secret' + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'expired-access-token', + token_type: 'bearer', + refresh_token: 'current-refresh-token' + }), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() + }; + + // First auth call + const result1 = await auth(sequentialProvider, { serverUrl: 'https://api.example.com/mcp-server' }); + expect(result1).toBe('AUTHORIZED'); + + // Second auth call (after first completes) should trigger a new refresh + const result2 = await auth(sequentialProvider, { serverUrl: 'https://api.example.com/mcp-server' }); + expect(result2).toBe('AUTHORIZED'); + + // Two separate token requests should have been made + expect(tokenRequestCount).toBe(2); + }); + + it('propagates errors to all concurrent callers', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: false, + status: 400, + text: async () => JSON.stringify({ + error: 'invalid_grant', + error_description: 'Refresh token has been revoked' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const errorProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client-id', + client_secret: 'client-secret' + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'expired-access-token', + token_type: 'bearer', + refresh_token: 'revoked-refresh-token' + }), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }; + + // Fire concurrent auth calls — all should get the same error handling result + // (invalid_grant triggers credential invalidation and retry, which leads to REDIRECT) + const results = await Promise.all([ + auth(errorProvider, { serverUrl: 'https://api.example.com/mcp-server' }), + auth(errorProvider, { serverUrl: 'https://api.example.com/mcp-server' }), + auth(errorProvider, { serverUrl: 'https://api.example.com/mcp-server' }) + ]); + + // All callers should get the same result + expect(results[0]).toBe(results[1]); + expect(results[1]).toBe(results[2]); + }); + + it('does not deduplicate auth calls for different providers', async () => { + let tokenRequestCount = 0; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString.includes('/token')) { + tokenRequestCount++; + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: `access-token-${tokenRequestCount}`, + token_type: 'bearer', + expires_in: 3600, + refresh_token: `refresh-token-${tokenRequestCount}` + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const makeProvider = (): OAuthClientProvider => ({ + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client-id', + client_secret: 'client-secret' + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'expired-access-token', + token_type: 'bearer', + refresh_token: 'current-refresh-token' + }), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() + }); + + const providerA = makeProvider(); + const providerB = makeProvider(); + + // Concurrent calls with different providers should NOT be deduplicated + const results = await Promise.all([ + auth(providerA, { serverUrl: 'https://api.example.com/mcp-server' }), + auth(providerB, { serverUrl: 'https://api.example.com/mcp-server' }) + ]); + + expect(results).toEqual(['AUTHORIZED', 'AUTHORIZED']); + + // Each provider should have triggered its own token refresh + expect(tokenRequestCount).toBe(2); + }); + }); }); From 94f69f3e5248912a83db1ceacd70ba1599f43a2c Mon Sep 17 00:00:00 2001 From: Adam Boudjemaa Date: Mon, 30 Mar 2026 00:03:27 +0200 Subject: [PATCH 2/2] style: fix prettier formatting in auth test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/client/test/client/auth.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 7202d4779..abd1c6430 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3875,10 +3875,11 @@ describe('OAuth Authorization', () => { return Promise.resolve({ ok: false, status: 400, - text: async () => JSON.stringify({ - error: 'invalid_grant', - error_description: 'Refresh token has been revoked' - }) + text: async () => + JSON.stringify({ + error: 'invalid_grant', + error_description: 'Refresh token has been revoked' + }) }); }