Skip to content

Commit 2c38245

Browse files
committed
fix(oauth): skip the dead flag when the chain moved, and return no token when the account is gone
1 parent 907acd8 commit 2c38245

2 files changed

Lines changed: 93 additions & 12 deletions

File tree

‎apps/sim/lib/oauth/credential-service.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
8888
import { isMicrosoftProvider } from '@/lib/oauth/microsoft'
8989
import { fanOutSlackTokenChain } from '@/lib/oauth/slack'
90+
import { isTerminalRefreshError, markCredentialDead } from '@/lib/oauth/terminal-errors'
9091
import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
9192

9293
const RAW_CREDENTIAL_ID = 'credential-raw-secret-id'
@@ -424,6 +425,7 @@ describe('OAuth access-token refresh headroom', () => {
424425

425426
afterEach(() => {
426427
vi.useRealTimers()
428+
vi.mocked(isTerminalRefreshError).mockReturnValue(false)
427429
vi.mocked(isInstagramProvider).mockReturnValue(false)
428430
vi.mocked(shouldProactivelyRefreshInstagramToken).mockReturnValue(false)
429431
vi.mocked(isMicrosoftProvider).mockReturnValue(false)
@@ -548,6 +550,47 @@ describe('OAuth access-token refresh headroom', () => {
548550
expect(guard).toContain('original-refresh-token')
549551
})
550552

553+
it('returns no token when the rotation write finds the account gone', async () => {
554+
queueCredentialAccount(createOAuthAccount())
555+
dbChainMockFns.returning.mockResolvedValueOnce([])
556+
queueTableRows(account, [])
557+
await expect(
558+
resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
559+
).resolves.toBeNull()
560+
expect(mocks.logger.warn).toHaveBeenCalledWith(
561+
'Rotation write found no account; the credential is gone',
562+
expect.anything()
563+
)
564+
})
565+
566+
it('does not flag a credential dead when a terminal failure follows a newer rotation', async () => {
567+
queueCredentialAccount(createOAuthAccount())
568+
vi.mocked(isTerminalRefreshError).mockReturnValue(true)
569+
mocks.refreshOAuthToken.mockResolvedValue({ ok: false, errorCode: 'invalid_grant' })
570+
queueTableRows(account, [
571+
{
572+
...createOAuthAccount(3_600_000),
573+
accessToken: 'winner-token',
574+
refreshToken: 'winner-refresh-token',
575+
},
576+
])
577+
await expect(
578+
resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
579+
).resolves.toEqual({ accessToken: 'winner-token' })
580+
expect(markCredentialDead).not.toHaveBeenCalled()
581+
})
582+
583+
it('flags a credential dead on a terminal failure when its chain did not move', async () => {
584+
queueCredentialAccount(createOAuthAccount())
585+
vi.mocked(isTerminalRefreshError).mockReturnValue(true)
586+
mocks.refreshOAuthToken.mockResolvedValue({ ok: false, errorCode: 'invalid_grant' })
587+
queueTableRows(account, [createOAuthAccount()])
588+
await expect(
589+
resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
590+
).resolves.toBeNull()
591+
expect(markCredentialDead).toHaveBeenCalledWith(expect.any(String), 'invalid_grant')
592+
})
593+
551594
it('uses the stored chain when the rotation write loses to a newer one', async () => {
552595
queueCredentialAccount(createOAuthAccount())
553596
/** Another writer rotated first: no row still holds the token this refresh started from. */

‎apps/sim/lib/oauth/credential-service.ts‎

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,37 @@ const REFRESH_LOCK_HEADROOM_MS = 15_000
886886
const REFRESH_LOCK_TTL_SEC = Math.ceil((TOKEN_REFRESH_TIMEOUT_MS + REFRESH_LOCK_HEADROOM_MS) / 1000)
887887
const REFRESH_FOLLOWER_MAX_WAIT_MS = REFRESH_LOCK_TTL_SEC * 1000
888888

889+
interface StoredChain {
890+
accessToken: string | null
891+
accessTokenExpiresAt: Date | null
892+
refreshToken: string | null
893+
}
894+
895+
/** The chain an account row holds now, or nothing when the account is gone. */
896+
async function readStoredChain(accountId: string): Promise<StoredChain | undefined> {
897+
const [stored] = await db
898+
.select({
899+
accessToken: account.accessToken,
900+
accessTokenExpiresAt: account.accessTokenExpiresAt,
901+
refreshToken: account.refreshToken,
902+
})
903+
.from(account)
904+
.where(eq(account.id, accountId))
905+
.limit(1)
906+
return stored
907+
}
908+
909+
/**
910+
* The stored access token when it can still serve a request, as a follower would take it: a
911+
* chain another writer just rotated carries one, and a token that has already expired is no
912+
* answer at all.
913+
*/
914+
function usableStoredToken(stored: StoredChain, providerId: string): string | null {
915+
return stored.accessToken && !isOAuthAccessTokenExpiring(stored.accessTokenExpiresAt, providerId)
916+
? stored.accessToken
917+
: null
918+
}
919+
889920
async function performCoalescedRefresh({
890921
accountId,
891922
providerId,
@@ -979,18 +1010,25 @@ async function performCoalescedRefresh({
9791010
message: result.message,
9801011
})
9811012
if (result.errorCode && isTerminalRefreshError(result.errorCode)) {
982-
// A refresh that lost a race with a concurrent connect fails with
983-
// a revoked/rotated-out token even though the installation just
984-
// got a live chain — dead-flagging then would take down a healthy
985-
// credential for an hour.
1013+
// A refresh that lost a race with a concurrent connect or a newer
1014+
// rotation fails with a revoked/rotated-out token even though the
1015+
// account just got a live chain — dead-flagging then would take
1016+
// down a healthy credential for an hour.
9861017
if (
9871018
slackChainVersion &&
9881019
(await hasSlackChainMoved(slackTeamId!, slackChainVersion))
9891020
) {
9901021
logger.info('Skipping dead flag: Slack chain moved during refresh', logContext)
991-
} else {
992-
await markCredentialDead(scopeKey, result.errorCode)
1022+
return null
9931023
}
1024+
if (!slackTeamId) {
1025+
const stored = await readStoredChain(accountId)
1026+
if (stored && stored.refreshToken !== refreshToken) {
1027+
logger.info('Skipping dead flag: chain moved during refresh', logContext)
1028+
return usableStoredToken(stored, providerId)
1029+
}
1030+
}
1031+
await markCredentialDead(scopeKey, result.errorCode)
9941032
}
9951033
return null
9961034
}
@@ -1041,16 +1079,16 @@ async function performCoalescedRefresh({
10411079
.where(and(eq(account.id, accountId), eq(account.refreshToken, refreshToken)))
10421080
.returning({ id: account.id })
10431081
if (rotated.length === 0) {
1082+
const stored = await readStoredChain(accountId)
1083+
if (!stored) {
1084+
logger.warn('Rotation write found no account; the credential is gone', logContext)
1085+
return null
1086+
}
10441087
logger.warn(
10451088
'Rotation write lost to a newer chain; using the stored token',
10461089
logContext
10471090
)
1048-
const [stored] = await db
1049-
.select({ accessToken: account.accessToken })
1050-
.from(account)
1051-
.where(eq(account.id, accountId))
1052-
.limit(1)
1053-
return stored?.accessToken ?? result.accessToken
1091+
return usableStoredToken(stored, providerId)
10541092
}
10551093
}
10561094

0 commit comments

Comments
 (0)