Skip to content

Commit 3708198

Browse files
fix(oauth): version-guard slack chain writes against concurrent connects
1 parent 54a38be commit 3708198

3 files changed

Lines changed: 118 additions & 20 deletions

File tree

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ describe('OAuth Utils', () => {
328328
expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][0]).toBe(
329329
'oauth:refresh:slack:T08CM6ZNYBE'
330330
)
331-
expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][2]).toBe(20)
331+
expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][2]).toBe(30)
332332
expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'live-rt')
333333
expect(mockSet).toHaveBeenCalledWith(
334334
expect.objectContaining({ accessToken: 'new-at', refreshToken: 'new-rt' })
@@ -384,6 +384,7 @@ describe('OAuth Utils', () => {
384384
ok: false,
385385
errorCode: 'token_revoked',
386386
})
387+
mockSelectChain([])
387388

388389
await expect(refreshTokenIfNeeded('request-id', slackCredential(), 'row-1')).rejects.toThrow(
389390
'Failed to refresh token'
@@ -396,6 +397,29 @@ describe('OAuth Utils', () => {
396397
3600
397398
)
398399
})
400+
401+
it('skips the dead flag when the chain moved during the failed refresh', async () => {
402+
const fakeRedis = {
403+
set: vi.fn().mockResolvedValue('OK'),
404+
get: vi.fn().mockResolvedValue(null),
405+
del: vi.fn().mockResolvedValue(1),
406+
}
407+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(fakeRedis)
408+
mockSelectOrderedChain([
409+
{ accessToken: 'stale-at', refreshToken: 'live-rt', accessTokenExpiresAt: past },
410+
])
411+
mockRefreshOAuthToken.mockResolvedValueOnce({
412+
ok: false,
413+
errorCode: 'token_revoked',
414+
})
415+
mockSelectChain([{ moved: new Date() }])
416+
417+
await expect(refreshTokenIfNeeded('request-id', slackCredential(), 'row-1')).rejects.toThrow(
418+
'Failed to refresh token'
419+
)
420+
421+
expect(fakeRedis.set).not.toHaveBeenCalled()
422+
})
399423
})
400424

401425
describe('resolveServiceAccountToken', () => {

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

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
extractSlackTeamId,
3232
fanOutSlackTokenChain,
3333
getFreshestSlackChain,
34+
hasSlackChainMoved,
3435
isSlackProvider,
3536
} from '@/lib/oauth/slack'
3637
import {
@@ -673,12 +674,15 @@ interface CoalescedRefreshOptions {
673674
/**
674675
* Slack lock budgets sized past `TOKEN_REFRESH_TIMEOUT_MS` (15s) in
675676
* lib/oauth/oauth.ts: installation-keyed locks make every sibling row's request
676-
* a follower of one refresh. The lock TTL must not expire under a live refresh
677-
* (15s provider call plus DB reads and the fan-out write), and followers poll
678-
* for the lock's full lifetime so a slow-but-successful refresh is still
679-
* observed rather than reported as a failure.
677+
* a follower of one refresh, so the TTL covers the provider call plus generous
678+
* headroom for the surrounding DB reads and the fan-out write, and followers
679+
* poll for the lock's full lifetime so a slow-but-successful refresh is still
680+
* observed rather than reported as a failure. These budgets are latency knobs,
681+
* not correctness guarantees — chain integrity under lock expiry or unlocked
682+
* concurrent writers is enforced by the version-guarded fan-out
683+
* (`ifChainUnchangedSince` in lib/oauth/slack.ts).
680684
*/
681-
const SLACK_LOCK_TTL_SEC = 20
685+
const SLACK_LOCK_TTL_SEC = 30
682686
const SLACK_FOLLOWER_MAX_WAIT_MS = SLACK_LOCK_TTL_SEC * 1000
683687

684688
async function performCoalescedRefresh({
@@ -727,23 +731,29 @@ async function performCoalescedRefresh({
727731
onLeader: async () => {
728732
try {
729733
let refreshTokenToUse = refreshToken
734+
let slackChainVersion: Date | null = null
730735
if (slackTeamId) {
731736
const freshest = await getFreshestSlackChain(slackTeamId)
732737
if (!freshest) {
733738
throw new Error(
734739
`No refresh-capable account row found for Slack installation ${slackTeamId}`
735740
)
736741
}
742+
slackChainVersion = freshest.chainVersion
737743
if (
738744
freshest.accessToken &&
739745
freshest.accessTokenExpiresAt &&
740746
freshest.accessTokenExpiresAt > new Date()
741747
) {
742-
await fanOutSlackTokenChain(slackTeamId, {
743-
accessToken: freshest.accessToken,
744-
refreshToken: freshest.refreshToken,
745-
accessTokenExpiresAt: freshest.accessTokenExpiresAt,
746-
})
748+
await fanOutSlackTokenChain(
749+
slackTeamId,
750+
{
751+
accessToken: freshest.accessToken,
752+
refreshToken: freshest.refreshToken,
753+
accessTokenExpiresAt: freshest.accessTokenExpiresAt,
754+
},
755+
{ ifChainUnchangedSince: freshest.chainVersion }
756+
)
747757
logger.info('Reused freshest Slack installation token', logContext)
748758
return freshest.accessToken
749759
}
@@ -758,19 +768,34 @@ async function performCoalescedRefresh({
758768
errorCode: result.errorCode,
759769
})
760770
if (result.errorCode && isTerminalRefreshError(result.errorCode)) {
761-
await markCredentialDead(scopeKey, result.errorCode)
771+
// A refresh that lost a race with a concurrent connect fails with
772+
// a revoked/rotated-out token even though the installation just
773+
// got a live chain — dead-flagging then would take down a healthy
774+
// credential for an hour.
775+
if (
776+
slackChainVersion &&
777+
(await hasSlackChainMoved(slackTeamId!, slackChainVersion))
778+
) {
779+
logger.info('Skipping dead flag: Slack chain moved during refresh', logContext)
780+
} else {
781+
await markCredentialDead(scopeKey, result.errorCode)
782+
}
762783
}
763784
return null
764785
}
765786

766787
const accessTokenExpiresAt = new Date(Date.now() + result.expiresIn * 1000)
767788

768789
if (slackTeamId) {
769-
await fanOutSlackTokenChain(slackTeamId, {
770-
accessToken: result.accessToken,
771-
refreshToken: result.refreshToken || refreshTokenToUse,
772-
accessTokenExpiresAt,
773-
})
790+
await fanOutSlackTokenChain(
791+
slackTeamId,
792+
{
793+
accessToken: result.accessToken,
794+
refreshToken: result.refreshToken || refreshTokenToUse,
795+
accessTokenExpiresAt,
796+
},
797+
{ ifChainUnchangedSince: slackChainVersion ?? undefined }
798+
)
774799
} else {
775800
const updateData: Record<string, unknown> = {
776801
accessToken: result.accessToken,

apps/sim/lib/oauth/slack.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db } from '@sim/db'
22
import { account } from '@sim/db/schema'
3-
import { and, eq, isNotNull, like, sql } from 'drizzle-orm'
3+
import { and, eq, gt, isNotNull, like, max, sql } from 'drizzle-orm'
44

55
/**
66
* Slack bot tokens belong to the installation (team × app), not to the OAuth
@@ -40,13 +40,31 @@ interface SlackTokenChain {
4040
accessTokenExpiresAt: Date | null
4141
}
4242

43+
interface FanOutOptions {
44+
/**
45+
* Version guard: skip the write entirely when any sibling row was updated
46+
* after this timestamp. A refresh leader holds its chain snapshot across a
47+
* multi-second provider call while a concurrent OAuth connect (whose
48+
* `account.create.after` fan-out takes no lock) may land the newly issued
49+
* chain first — an unconditional write would then overwrite the live chain
50+
* with a stale one. Omit for connect-time fan-out, whose chain is by
51+
* definition the newest.
52+
*/
53+
ifChainUnchangedSince?: Date
54+
}
55+
4356
/**
4457
* Writes a token chain to every account row of a Slack installation. Rotation
4558
* revokes whatever the sibling rows were holding, so a successful refresh or a
4659
* fresh connect must overwrite all copies or the stale ones fail with
4760
* `token_revoked` at call time.
4861
*/
49-
export async function fanOutSlackTokenChain(teamId: string, chain: SlackTokenChain): Promise<void> {
62+
export async function fanOutSlackTokenChain(
63+
teamId: string,
64+
chain: SlackTokenChain,
65+
options?: FanOutOptions
66+
): Promise<void> {
67+
const since = options?.ifChainUnchangedSince
5068
await db
5169
.update(account)
5270
.set({
@@ -55,13 +73,40 @@ export async function fanOutSlackTokenChain(teamId: string, chain: SlackTokenCha
5573
...(chain.refreshToken ? { refreshToken: chain.refreshToken } : {}),
5674
updatedAt: new Date(),
5775
})
58-
.where(installationFilter(teamId))
76+
.where(
77+
and(
78+
installationFilter(teamId),
79+
since
80+
? sql`NOT EXISTS (SELECT 1 FROM ${account} sibling WHERE sibling.provider_id = 'slack' AND sibling.account_id LIKE ${`${teamId}-%`} AND sibling.updated_at > ${since})`
81+
: undefined
82+
)
83+
)
84+
}
85+
86+
/**
87+
* True when any account row of the installation was updated after `since` —
88+
* i.e. another writer (a fresh connect or a competing refresh) landed a newer
89+
* chain while the caller was working from an older snapshot.
90+
*/
91+
export async function hasSlackChainMoved(teamId: string, since: Date): Promise<boolean> {
92+
const [row] = await db
93+
.select({ moved: max(account.updatedAt) })
94+
.from(account)
95+
.where(and(installationFilter(teamId), gt(account.updatedAt, since)))
96+
.limit(1)
97+
return row?.moved != null
5998
}
6099

61100
interface FreshestSlackChain {
62101
accessToken: string | null
63102
refreshToken: string
64103
accessTokenExpiresAt: Date | null
104+
/**
105+
* Max `updated_at` across the installation's rows at read time — the version
106+
* guard passed back into {@link fanOutSlackTokenChain} / consulted via
107+
* {@link hasSlackChainMoved} after the provider round-trip.
108+
*/
109+
chainVersion: Date
65110
}
66111

67112
/**
@@ -76,6 +121,9 @@ export async function getFreshestSlackChain(teamId: string): Promise<FreshestSla
76121
accessToken: account.accessToken,
77122
refreshToken: account.refreshToken,
78123
accessTokenExpiresAt: account.accessTokenExpiresAt,
124+
chainVersion: sql<
125+
Date | string | null
126+
>`(SELECT max(sibling.updated_at) FROM ${account} sibling WHERE sibling.provider_id = 'slack' AND sibling.account_id LIKE ${`${teamId}-%`})`,
79127
})
80128
.from(account)
81129
.where(and(installationFilter(teamId), isNotNull(account.refreshToken)))
@@ -87,5 +135,6 @@ export async function getFreshestSlackChain(teamId: string): Promise<FreshestSla
87135
accessToken: row.accessToken,
88136
refreshToken: row.refreshToken,
89137
accessTokenExpiresAt: row.accessTokenExpiresAt,
138+
chainVersion: row.chainVersion ? new Date(row.chainVersion) : new Date(0),
90139
}
91140
}

0 commit comments

Comments
 (0)