Skip to content

Commit 93fdd13

Browse files
authored
fix(oauth): rotate a refresh chain only from the token it started from and size the refresh lease for every provider (#8152)
* fix(oauth): rotate a refresh chain only from the token it started from and size the refresh lease for every provider * fix(oauth): skip the dead flag when the chain moved, and return no token when the account is gone
1 parent 3e1e1fc commit 93fdd13

5 files changed

Lines changed: 179 additions & 27 deletions

File tree

‎apps/sim/app/api/auth/oauth/utils.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1010
vi.mock('@/lib/oauth/oauth', () => ({
1111
refreshOAuthToken: vi.fn(),
1212
OAUTH_PROVIDERS: {},
13+
TOKEN_REFRESH_TIMEOUT_MS: 15_000,
1314
}))
1415

1516
const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
@@ -65,7 +66,9 @@ function mockSelectChain(limitResult: unknown[]) {
6566
* Returns a nested chain: update() -> set() -> where()
6667
*/
6768
function mockUpdateChain() {
68-
const mockWhere = vi.fn().mockResolvedValue({})
69+
/** The rotated write returns the row it matched; an empty result means the chain moved first. */
70+
const mockReturning = vi.fn().mockResolvedValue([{ id: 'account-1' }])
71+
const mockWhere = vi.fn().mockReturnValue({ returning: mockReturning })
6972
const mockSet = vi.fn().mockReturnValue({ where: mockWhere })
7073
mockDb.update.mockReturnValueOnce({ set: mockSet })
7174
return { mockSet, mockWhere }

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ vi.mock('@/lib/oauth/microsoft', () => ({
5454
vi.mock('@/lib/oauth/oauth', () => ({
5555
OAUTH_PROVIDERS: {},
5656
refreshOAuthToken: mocks.refreshOAuthToken,
57+
TOKEN_REFRESH_TIMEOUT_MS: 15_000,
5758
}))
5859

5960
vi.mock('@/lib/oauth/quickbooks-client-config', () => ({
@@ -86,6 +87,7 @@ import {
8687
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
8788
import { isMicrosoftProvider } from '@/lib/oauth/microsoft'
8889
import { fanOutSlackTokenChain } from '@/lib/oauth/slack'
90+
import { isTerminalRefreshError, markCredentialDead } from '@/lib/oauth/terminal-errors'
8991
import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
9092

9193
const RAW_CREDENTIAL_ID = 'credential-raw-secret-id'
@@ -243,6 +245,7 @@ describe('resolveCredentialTokenBundle selector privacy', () => {
243245
environment: 'sandbox',
244246
webhookVerifierToken: 'verifier-token',
245247
})
248+
dbChainMockFns.returning.mockResolvedValue([{ id: RAW_ACCOUNT_ID }])
246249
mocks.refreshOAuthToken.mockResolvedValue({
247250
ok: true,
248251
accessToken: 'new-access-token',
@@ -416,10 +419,13 @@ describe('OAuth access-token refresh headroom', () => {
416419
refreshToken: 'rotated-refresh-token',
417420
expiresIn: 3600,
418421
})
422+
/** The rotated write matches the row unless a test makes the chain move first. */
423+
dbChainMockFns.returning.mockResolvedValue([{ id: RAW_ACCOUNT_ID }])
419424
})
420425

421426
afterEach(() => {
422427
vi.useRealTimers()
428+
vi.mocked(isTerminalRefreshError).mockReturnValue(false)
423429
vi.mocked(isInstagramProvider).mockReturnValue(false)
424430
vi.mocked(shouldProactivelyRefreshInstagramToken).mockReturnValue(false)
425431
vi.mocked(isMicrosoftProvider).mockReturnValue(false)
@@ -523,6 +529,83 @@ describe('OAuth access-token refresh headroom', () => {
523529
expect(mocks.refreshOAuthToken).not.toHaveBeenCalled()
524530
})
525531

532+
it('sizes the lease and the follower wait past the provider timeout for every provider', async () => {
533+
queueCredentialAccount(createOAuthAccount())
534+
await resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
535+
expect(mocks.withLeaderLock).toHaveBeenCalledWith(
536+
expect.objectContaining({ ttlSec: 30, maxWaitMs: 30_000 })
537+
)
538+
})
539+
540+
it('rotates the chain only from the refresh token the refresh started from', async () => {
541+
queueCredentialAccount(createOAuthAccount())
542+
await expect(
543+
resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
544+
).resolves.toEqual({ accessToken: 'refreshed-access-token' })
545+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
546+
expect.objectContaining({ refreshToken: 'rotated-refresh-token' })
547+
)
548+
const guard = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1))
549+
expect(guard).toContain('account.refreshToken')
550+
expect(guard).toContain('original-refresh-token')
551+
})
552+
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+
594+
it('uses the stored chain when the rotation write loses to a newer one', async () => {
595+
queueCredentialAccount(createOAuthAccount())
596+
/** Another writer rotated first: no row still holds the token this refresh started from. */
597+
dbChainMockFns.returning.mockResolvedValueOnce([])
598+
queueTableRows(account, [{ ...createOAuthAccount(3_600_000), accessToken: 'winner-token' }])
599+
await expect(
600+
resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test')
601+
).resolves.toEqual({ accessToken: 'winner-token' })
602+
expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(1)
603+
expect(mocks.logger.warn).toHaveBeenCalledWith(
604+
'Rotation write lost to a newer chain; using the stored token',
605+
expect.anything()
606+
)
607+
})
608+
526609
it.each([
527610
{ remainingMs: 6_000, refresh: true },
528611
{ remainingMs: 300_000, refresh: true },

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

Lines changed: 86 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
isMicrosoftProvider,
3737
PROACTIVE_REFRESH_THRESHOLD_DAYS,
3838
} from '@/lib/oauth/microsoft'
39-
import { refreshOAuthToken } from '@/lib/oauth/oauth'
39+
import { refreshOAuthToken, TOKEN_REFRESH_TIMEOUT_MS } from '@/lib/oauth/oauth'
4040
import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config'
4141
import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
4242
import {
@@ -872,18 +872,50 @@ function isOAuthAccessTokenExpiring(
872872
}
873873

874874
/**
875-
* Slack lock budgets sized past `TOKEN_REFRESH_TIMEOUT_MS` (15s) in
876-
* lib/oauth/oauth.ts: installation-keyed locks make every sibling row's request
877-
* a follower of one refresh, so the TTL covers the provider call plus generous
878-
* headroom for the surrounding DB reads and the fan-out write, and followers
879-
* poll for the lock's full lifetime so a slow-but-successful refresh is still
880-
* observed rather than reported as a failure. These budgets are latency knobs,
881-
* not correctness guarantees — chain integrity under lock expiry or unlocked
882-
* concurrent writers is enforced by the version-guarded fan-out
883-
* (`ifChainUnchangedSince` in lib/oauth/slack.ts).
875+
* Lock budgets sized past the provider call: the lease covers
876+
* {@link TOKEN_REFRESH_TIMEOUT_MS} plus headroom for the account read before it and
877+
* the rotated write after it, so a leader still talking to a slow provider keeps its
878+
* lease instead of letting a second leader start a competing rotation; and followers
879+
* poll for the lease's full lifetime, so a slow-but-successful refresh is observed
880+
* rather than reported as a failure. Both are latency knobs, not correctness
881+
* guarantees: a lease is only ever a lease, and chain integrity under lock expiry or
882+
* an unlocked writer is enforced at the write, which rotates a chain only from the
883+
* refresh token it started from (`ifChainUnchangedSince` for a Slack installation).
884884
*/
885-
const SLACK_LOCK_TTL_SEC = 30
886-
const SLACK_FOLLOWER_MAX_WAIT_MS = SLACK_LOCK_TTL_SEC * 1000
885+
const REFRESH_LOCK_HEADROOM_MS = 15_000
886+
const REFRESH_LOCK_TTL_SEC = Math.ceil((TOKEN_REFRESH_TIMEOUT_MS + REFRESH_LOCK_HEADROOM_MS) / 1000)
887+
const REFRESH_FOLLOWER_MAX_WAIT_MS = REFRESH_LOCK_TTL_SEC * 1000
888+
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+
}
887919

888920
async function performCoalescedRefresh({
889921
accountId,
@@ -926,11 +958,8 @@ async function performCoalescedRefresh({
926958
const refreshPromise = coalesceLocally(lockKey, () =>
927959
withLeaderLock<string>({
928960
key: lockKey,
929-
// Installation-keyed Slack locks gather followers from every sibling row,
930-
// so their wait and the lock TTL must outlast the 15s provider timeout —
931-
// the 3s/10s defaults would fail followers early and let a second leader
932-
// start a concurrent rotation mid-refresh.
933-
...(slackTeamId ? { maxWaitMs: SLACK_FOLLOWER_MAX_WAIT_MS, ttlSec: SLACK_LOCK_TTL_SEC } : {}),
961+
ttlSec: REFRESH_LOCK_TTL_SEC,
962+
maxWaitMs: REFRESH_FOLLOWER_MAX_WAIT_MS,
934963
onLeader: async () => {
935964
try {
936965
let refreshTokenToUse = refreshToken
@@ -981,18 +1010,25 @@ async function performCoalescedRefresh({
9811010
message: result.message,
9821011
})
9831012
if (result.errorCode && isTerminalRefreshError(result.errorCode)) {
984-
// A refresh that lost a race with a concurrent connect fails with
985-
// a revoked/rotated-out token even though the installation just
986-
// got a live chain — dead-flagging then would take down a healthy
987-
// 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.
9881017
if (
9891018
slackChainVersion &&
9901019
(await hasSlackChainMoved(slackTeamId!, slackChainVersion))
9911020
) {
9921021
logger.info('Skipping dead flag: Slack chain moved during refresh', logContext)
993-
} else {
994-
await markCredentialDead(scopeKey, result.errorCode)
1022+
return null
9951023
}
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)
9961032
}
9971033
return null
9981034
}
@@ -1027,7 +1063,33 @@ async function performCoalescedRefresh({
10271063
)
10281064
}
10291065

1030-
await db.update(account).set(updateData).where(eq(account.id, accountId))
1066+
/**
1067+
* The chain is rotated only from the refresh token this refresh started from.
1068+
* A lease is not mutual exclusion: it can expire under a slow provider or a
1069+
* paused process while the leader is still running, and an unconditional
1070+
* write would then let this refresh overwrite a newer rotation with a chain
1071+
* the provider has already retired, which the next refresh pays for as
1072+
* `invalid_grant` and, under reuse detection, as a revoked grant. When no row
1073+
* matches, another writer rotated first: its chain is the live one, so this
1074+
* caller uses what is stored and never retries the provider.
1075+
*/
1076+
const rotated = await db
1077+
.update(account)
1078+
.set(updateData)
1079+
.where(and(eq(account.id, accountId), eq(account.refreshToken, refreshToken)))
1080+
.returning({ id: account.id })
1081+
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+
}
1087+
logger.warn(
1088+
'Rotation write lost to a newer chain; using the stored token',
1089+
logContext
1090+
)
1091+
return usableStoredToken(stored, providerId)
1092+
}
10311093
}
10321094

10331095
logger.info('Successfully refreshed access token', logContext)

‎apps/sim/lib/oauth/github-installation-credential.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ vi.mock('@/lib/oauth/github-installation', () => ({
1313
parseGitHubInstallationBinding: mocks.parseBinding,
1414
resolveGitHubInstallationAccessToken: mocks.resolveToken,
1515
}))
16-
vi.mock('@/lib/oauth/oauth', () => ({ OAUTH_PROVIDERS: {}, refreshOAuthToken: vi.fn() }))
16+
vi.mock('@/lib/oauth/oauth', () => ({
17+
OAUTH_PROVIDERS: {},
18+
refreshOAuthToken: vi.fn(),
19+
TOKEN_REFRESH_TIMEOUT_MS: 15_000,
20+
}))
1721

1822
import { resolveServiceAccountToken } from '@/lib/oauth/credential-service'
1923
import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types'

‎apps/sim/lib/oauth/oauth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2250,7 +2250,7 @@ function safeOAuthErrorCode(value: unknown, secrets: string[]): string | undefin
22502250
* Without this bound a hung endpoint would wedge every joiner on that key until
22512251
* the undici socket defaults (~5 min) gave up.
22522252
*/
2253-
const TOKEN_REFRESH_TIMEOUT_MS = 15_000
2253+
export const TOKEN_REFRESH_TIMEOUT_MS = 15_000
22542254

22552255
function parseOAuthResponse(responseText: string): unknown {
22562256
try {

0 commit comments

Comments
 (0)