From c527438d1b965720d6c15adc910dabebe0e14f81 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 22 Sep 2026 15:21:10 -0700 Subject: [PATCH 1/2] fix(oauth): scope unauthorized_client as terminal to Atlassian refreshes --- .../oauth/__tests__/terminal-errors.test.ts | 12 ++++++++- apps/sim/lib/oauth/credential-service.test.ts | 1 + apps/sim/lib/oauth/credential-service.ts | 2 +- apps/sim/lib/oauth/terminal-errors.ts | 27 ++++++++++++------- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts index a7979c6a4e1..83f7ad3c5b0 100644 --- a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts +++ b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts @@ -50,11 +50,21 @@ describe('isTerminalRefreshError', () => { 'invalid_client', 'bad_redirect_uri', 'token_revoked', - 'unauthorized_client', ])('returns true for %s', (code) => { expect(isTerminalRefreshError(code)).toBe(true) }) + it.each(['confluence', 'jira'])('treats unauthorized_client as terminal for %s', (providerId) => { + expect(isTerminalRefreshError('unauthorized_client', providerId)).toBe(true) + }) + + it.each([undefined, 'microsoft', 'salesforce', 'google-email'])( + 'does not treat unauthorized_client as terminal for %s', + (providerId) => { + expect(isTerminalRefreshError('unauthorized_client', providerId)).toBe(false) + } + ) + it.each(['ratelimited', 'internal_error', 'service_unavailable', undefined, null, ''])( 'returns false for %s', (code) => { diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 7808e1ef8cb..b3e499b50f2 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -591,6 +591,7 @@ describe('OAuth access-token refresh headroom', () => { resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') ).resolves.toBeNull() expect(markCredentialDead).toHaveBeenCalledWith(expect.any(String), 'invalid_grant') + expect(isTerminalRefreshError).toHaveBeenCalledWith('invalid_grant', 'google-drive') }) it('uses the stored chain when the rotation write loses to a newer one', async () => { diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index b3d882052cf..a1d7f1dbea3 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -1047,7 +1047,7 @@ async function performCoalescedRefresh({ errorCode: result.errorCode, message: result.message, }) - if (result.errorCode && isTerminalRefreshError(result.errorCode)) { + if (result.errorCode && isTerminalRefreshError(result.errorCode, providerId)) { // A refresh that lost a race with a concurrent connect or a newer // rotation fails with a revoked/rotated-out token even though the // account just got a live chain — dead-flagging then would take diff --git a/apps/sim/lib/oauth/terminal-errors.ts b/apps/sim/lib/oauth/terminal-errors.ts index 6c4eefcee76..246ad7c5dfc 100644 --- a/apps/sim/lib/oauth/terminal-errors.ts +++ b/apps/sim/lib/oauth/terminal-errors.ts @@ -4,12 +4,7 @@ import { getRedisClient } from '@/lib/core/config/redis' const logger = createLogger('OAuthTerminalErrors') -/** - * Refresh error codes that no retry can recover from: the credential stays dead until - * its owner reconnects. `unauthorized_client` is how Atlassian rejects a revoked or - * rotated-out refresh token, and under RFC 6749 section 5.2 it otherwise means the - * client may not use the refresh grant, which is equally persistent. - */ +/** Refresh error codes that no retry can recover from: the credential stays dead until its owner reconnects. */ const TERMINAL_ERRORS = new Set([ 'invalid_refresh_token', 'bad_refresh_token', @@ -20,18 +15,32 @@ const TERMINAL_ERRORS = new Set([ 'invalid_client', 'bad_redirect_uri', 'token_revoked', - 'unauthorized_client', ]) +/** + * Codes terminal only for the providers listed. Atlassian rejects a revoked or rotated-out + * refresh token with `unauthorized_client`; elsewhere that code usually describes the app + * registration, and treating it as terminal would send every credential of the provider to + * reauthorization over one configuration fault. + */ +const PROVIDER_TERMINAL_ERRORS: Readonly>> = { + confluence: new Set(['unauthorized_client']), + jira: new Set(['unauthorized_client']), +} + const DEAD_CACHE_TTL_SEC = 60 * 60 function deadKey(accountId: string): string { return `oauth:dead:${accountId}` } -export function isTerminalRefreshError(code: string | undefined | null): boolean { +export function isTerminalRefreshError( + code: string | undefined | null, + providerId?: string +): boolean { if (!code) return false - return TERMINAL_ERRORS.has(code) + if (TERMINAL_ERRORS.has(code)) return true + return providerId !== undefined && (PROVIDER_TERMINAL_ERRORS[providerId]?.has(code) ?? false) } export async function markCredentialDead(accountId: string, code: string): Promise { From eb5892f8de433a1790b8be5a2b51e2a7b912f08c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 22 Sep 2026 15:27:59 -0700 Subject: [PATCH 2/2] fix(oauth): look up provider terminal codes in a Map --- apps/sim/lib/oauth/__tests__/terminal-errors.test.ts | 2 +- apps/sim/lib/oauth/terminal-errors.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts index 83f7ad3c5b0..d4a9802c396 100644 --- a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts +++ b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts @@ -58,7 +58,7 @@ describe('isTerminalRefreshError', () => { expect(isTerminalRefreshError('unauthorized_client', providerId)).toBe(true) }) - it.each([undefined, 'microsoft', 'salesforce', 'google-email'])( + it.each([undefined, 'microsoft', 'salesforce', 'google-email', 'constructor', '__proto__'])( 'does not treat unauthorized_client as terminal for %s', (providerId) => { expect(isTerminalRefreshError('unauthorized_client', providerId)).toBe(false) diff --git a/apps/sim/lib/oauth/terminal-errors.ts b/apps/sim/lib/oauth/terminal-errors.ts index 246ad7c5dfc..bd5e20c83b4 100644 --- a/apps/sim/lib/oauth/terminal-errors.ts +++ b/apps/sim/lib/oauth/terminal-errors.ts @@ -23,10 +23,10 @@ const TERMINAL_ERRORS = new Set([ * registration, and treating it as terminal would send every credential of the provider to * reauthorization over one configuration fault. */ -const PROVIDER_TERMINAL_ERRORS: Readonly>> = { - confluence: new Set(['unauthorized_client']), - jira: new Set(['unauthorized_client']), -} +const PROVIDER_TERMINAL_ERRORS: ReadonlyMap> = new Map([ + ['confluence', new Set(['unauthorized_client'])], + ['jira', new Set(['unauthorized_client'])], +]) const DEAD_CACHE_TTL_SEC = 60 * 60 @@ -40,7 +40,7 @@ export function isTerminalRefreshError( ): boolean { if (!code) return false if (TERMINAL_ERRORS.has(code)) return true - return providerId !== undefined && (PROVIDER_TERMINAL_ERRORS[providerId]?.has(code) ?? false) + return providerId !== undefined && (PROVIDER_TERMINAL_ERRORS.get(providerId)?.has(code) ?? false) } export async function markCredentialDead(accountId: string, code: string): Promise {