From f7e365cf44799350bc9babe39d196a54e896eef6 Mon Sep 17 00:00:00 2001 From: kmaclip Date: Fri, 27 Mar 2026 11:29:49 -0400 Subject: [PATCH] fix: deduplicate concurrent OAuth refresh token exchanges When multiple parallel requests receive 401 responses, each independently calls onUnauthorized -> handleOAuthUnauthorized -> refreshAuthorization with the same refresh token. OAuth providers using rotating refresh tokens (Atlassian, Asana, per RFC 6819 5.2.2.3) detect the second use as a replay attack and revoke the entire token family, logging the user out. The fix adds promise coalescing in adaptOAuthProvider: the first 401 handler stores its refresh promise, and all concurrent 401s await the same promise instead of initiating separate refresh exchanges. The promise is cleared after completion (success or failure) so future token refreshes proceed normally. Closes #1760 --- packages/client/src/client/auth.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 1a021be18..669a2f2bd 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -120,12 +120,25 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx * original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 upscoping). */ export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider { + let inflightRefresh: Promise | undefined; return { token: async () => { const tokens = await provider.tokens(); return tokens?.access_token; }, - onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx) + onUnauthorized: async ctx => { + // Deduplicate concurrent 401 handlers to prevent multiple + // refresh token exchanges. OAuth providers with rotating + // refresh tokens (RFC 6819 5.2.2.3) revoke the entire + // token family when a refresh token is used more than once. + if (inflightRefresh) { + return inflightRefresh; + } + inflightRefresh = handleOAuthUnauthorized(provider, ctx).finally(() => { + inflightRefresh = undefined; + }); + return inflightRefresh; + } }; }