Skip to content
Merged
Show file tree
Hide file tree
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
12 changes: 11 additions & 1 deletion apps/sim/lib/oauth/__tests__/terminal-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', 'constructor', '__proto__'])(
'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) => {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/oauth/credential-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/oauth/credential-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions apps/sim/lib/oauth/terminal-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
'invalid_refresh_token',
'bad_refresh_token',
Expand All @@ -20,7 +15,17 @@ const TERMINAL_ERRORS = new Set<string>([
'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: ReadonlyMap<string, ReadonlySet<string>> = new Map([
['confluence', new Set(['unauthorized_client'])],
['jira', new Set(['unauthorized_client'])],
])

const DEAD_CACHE_TTL_SEC = 60 * 60
Expand All @@ -29,9 +34,13 @@ 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.get(providerId)?.has(code) ?? false)
}

export async function markCredentialDead(accountId: string, code: string): Promise<void> {
Expand Down
Loading