Skip to content

Commit a3a0350

Browse files
committed
fix(credentials): keep the refused grant's tokens on the identity's existing rows
1 parent 4a89897 commit a3a0350

3 files changed

Lines changed: 107 additions & 74 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 59 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ import { CredentialAccountConflictError } from '@/lib/credentials/draft-hooks'
147147
import {
148148
captureOAuthCredentialDraftBinding,
149149
consumeOAuthCredentialDraftBinding,
150-
findCredentialReconnectConflictRedirect,
151150
processCredentialDraft,
151+
refuseConflictingCredentialReconnect,
152152
} from '@/lib/credentials/draft-processor'
153153
import { sendEmail } from '@/lib/messaging/email/mailer'
154154
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
@@ -297,6 +297,40 @@ async function fetchSalesforceInstanceUrl(
297297
}
298298
}
299299

300+
/**
301+
* A fresh Slack connect re-issues the installation's rotating token chain,
302+
* invalidating the copies held by sibling account rows for the same team (Slack
303+
* bot tokens are per-installation, not per-grant). Propagates the new chain so
304+
* every sibling is valid again, and clears the installation's dead flag. A
305+
* failure is logged, never thrown: the connect itself already succeeded.
306+
*/
307+
async function propagateFreshSlackConnect(account: {
308+
userId: string
309+
providerId: string
310+
accountId: string
311+
accessToken?: string | null
312+
refreshToken?: string | null
313+
accessTokenExpiresAt?: Date | null
314+
}): Promise<void> {
315+
if (account.providerId !== 'slack' || !account.accessToken) return
316+
try {
317+
const teamId = extractSlackTeamId(account.accountId)
318+
if (!teamId) return
319+
// Clear the dead flag before fanning out: the connect itself proves the
320+
// installation has live tokens, and a fan-out failure must not leave the
321+
// hour-long flag blocking refreshes.
322+
await clearOAuthRefreshDeadFlag(`slack:${teamId}`)
323+
await fanOutSlackTokenChain(teamId, {
324+
accessToken: account.accessToken,
325+
refreshToken: account.refreshToken ?? null,
326+
accessTokenExpiresAt: account.accessTokenExpiresAt ?? null,
327+
})
328+
logger.info('Propagated Slack installation token chain', { userId: account.userId, teamId })
329+
} catch (error) {
330+
logger.error('Failed to propagate Slack token chain', { userId: account.userId, error })
331+
}
332+
}
333+
300334
export const auth = betterAuth({
301335
baseURL: getBaseUrl(),
302336
// Where Better Auth sends OAuth callbacks that fail before the flow state is
@@ -503,6 +537,7 @@ export const auth = betterAuth({
503537
create: {
504538
before: async (account, context) => {
505539
const modifiedAccount = { ...account }
540+
let reconnectDraftId: string | undefined
506541

507542
if (context?.path.startsWith('/oauth2/callback/')) {
508543
let draftId: string | undefined
@@ -516,26 +551,7 @@ export const auth = betterAuth({
516551
})
517552
throw error
518553
}
519-
520-
/**
521-
* The after-hook runs once the callback has already answered, so a
522-
* reconnect onto an account another credential in the workspace
523-
* uses has to be refused here, before the row exists, to send the
524-
* user back with a reason instead of a 500.
525-
*/
526-
if (draftId) {
527-
const conflictRedirectUrl = await findCredentialReconnectConflictRedirect({
528-
draftId,
529-
userId: account.userId,
530-
providerId: account.providerId,
531-
externalAccountId: account.accountId,
532-
oauthState: await getOAuthState(),
533-
})
534-
if (conflictRedirectUrl) {
535-
consumeOAuthCredentialDraftBinding(context)
536-
throw new APIError('FOUND', undefined, { location: conflictRedirectUrl })
537-
}
538-
}
554+
reconnectDraftId = draftId
539555
}
540556

541557
if (account.accessToken && isSalesforceOAuthProviderId(account.providerId)) {
@@ -562,6 +578,27 @@ export const auth = betterAuth({
562578
}
563579
}
564580

581+
/**
582+
* The after-hook runs once the callback has already answered, so a
583+
* reconnect onto an account another credential in the workspace uses
584+
* has to be refused here, before the row exists, to send the user
585+
* back with a reason instead of a 500. The fresh grant still lands
586+
* on the identity's existing rows: the provider may already have
587+
* rotated their tokens away (Slack installations do).
588+
*/
589+
if (context && reconnectDraftId) {
590+
const conflictRedirectUrl = await refuseConflictingCredentialReconnect({
591+
draftId: reconnectDraftId,
592+
account: modifiedAccount,
593+
oauthState: await getOAuthState(),
594+
})
595+
if (conflictRedirectUrl) {
596+
consumeOAuthCredentialDraftBinding(context)
597+
await propagateFreshSlackConnect(modifiedAccount)
598+
throw new APIError('FOUND', undefined, { location: conflictRedirectUrl })
599+
}
600+
}
601+
565602
return { data: modifiedAccount }
566603
},
567604
after: async (account, context) => {
@@ -618,40 +655,7 @@ export const auth = betterAuth({
618655
})
619656
}
620657

621-
/**
622-
* A fresh Slack connect re-issues the installation's rotating token
623-
* chain, invalidating the copies held by sibling account rows for the
624-
* same team (Slack bot tokens are per-installation, not per-grant).
625-
* Propagate the new chain so every sibling is valid again, and clear
626-
* the installation's dead flag.
627-
*/
628-
if (account.providerId === 'slack' && account.accessToken) {
629-
try {
630-
const teamId = extractSlackTeamId(account.accountId)
631-
if (teamId) {
632-
// Clear the dead flag before fanning out: the connect itself
633-
// proves the installation has live tokens, and a fan-out
634-
// failure must not leave the hour-long flag blocking refreshes.
635-
await clearOAuthRefreshDeadFlag(`slack:${teamId}`)
636-
await fanOutSlackTokenChain(teamId, {
637-
accessToken: account.accessToken,
638-
refreshToken: account.refreshToken ?? null,
639-
accessTokenExpiresAt: account.accessTokenExpiresAt ?? null,
640-
})
641-
logger.info('[account.create.after] Propagated Slack installation token chain', {
642-
userId: account.userId,
643-
teamId,
644-
newAccountId: account.id,
645-
})
646-
}
647-
} catch (error) {
648-
logger.error('[account.create.after] Failed to propagate Slack token chain', {
649-
userId: account.userId,
650-
accountId: account.id,
651-
error,
652-
})
653-
}
654-
}
658+
await propagateFreshSlackConnect(account)
655659

656660
const isOAuth2Callback = context?.path.startsWith('/oauth2/callback/') === true
657661
const credentialDraftBinding = context

‎apps/sim/lib/credentials/__integration__/oauth-reconnect-account-conflict.integration.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { db } from '@sim/db'
77
import { account, credential, pendingCredentialDraft, user, workspace } from '@sim/db/schema'
88
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
9+
import { jsonResponse } from '@sim/testing/helpers/http'
910
import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock'
1011
import { emailTemplatesMock } from '@sim/testing/mocks/email-templates.mock'
1112
import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
@@ -54,13 +55,6 @@ function cookieHeader(response: Response, previous = '') {
5455
return [...cookies].map(([name, value]) => `${name}=${value}`).join('; ')
5556
}
5657

57-
function jsonResponse(body: unknown) {
58-
return new Response(JSON.stringify(body), {
59-
status: 200,
60-
headers: { 'content-type': 'application/json' },
61-
})
62-
}
63-
6458
describe('reconnecting a credential onto an account another credential uses', () => {
6559
let auth: typeof import('@/lib/auth/auth').auth
6660
let sessionCookie: string
@@ -240,7 +234,7 @@ describe('reconnecting a credential onto an account another credential uses', ()
240234
.where(and(eq(account.userId, userId), eq(account.providerId, PROVIDER_ID)))
241235
}
242236

243-
it('sends the user back to the reconnect page with the reason and changes nothing', async () => {
237+
it('sends the user back with the reason, keeping each credential on its account', async () => {
244238
const { callback, draftId, startedFrom } = await reconnectWorkCredentialAs(personalSubject)
245239

246240
expect(callback.status).toBe(302)
@@ -258,7 +252,10 @@ describe('reconnecting a credential onto an account another credential uses', ()
258252
expect(rows.map((row) => row.id).sort()).toEqual(
259253
[workAccountRowId, personalAccountRowId].sort()
260254
)
261-
expect(rows.find((row) => row.id === personalAccountRowId)?.accessToken).toBe('personal-access')
255+
expect(rows.find((row) => row.id === personalAccountRowId)?.accessToken).toBe(
256+
`fresh-access-${personalSubject}`
257+
)
258+
expect(rows.find((row) => row.id === workAccountRowId)?.accessToken).toBe('work-access')
262259
const drafts = await db
263260
.select({ id: pendingCredentialDraft.id })
264261
.from(pendingCredentialDraft)

‎apps/sim/lib/credentials/draft-processor.ts‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { db } from '@sim/db'
22
import * as schema from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { and, desc, eq, sql } from 'drizzle-orm'
4+
import { filterUndefined } from '@sim/utils/object'
5+
import { and, desc, eq, inArray, sql } from 'drizzle-orm'
56
import { stableExternalAccountId } from '@/lib/auth/connectors/account-id'
67
import { getBaseUrl } from '@/lib/core/utils/urls'
78
import {
@@ -15,6 +16,7 @@ import {
1516
} from '@/lib/credentials/draft-hooks'
1617
import { completeOrganizationCredentialDraft } from '@/lib/credentials/organization-draft'
1718
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
19+
import { clearOAuthRefreshFailure } from '@/lib/oauth/credential-service'
1820
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
1921
import type { OAuthProvider } from '@/lib/oauth/types'
2022
import { parseProvider } from '@/lib/oauth/utils'
@@ -113,10 +115,18 @@ export function consumeOAuthCredentialDraftBinding(
113115

114116
interface CredentialReconnectConflictParams {
115117
draftId: string
116-
userId: string
117-
providerId: string
118-
/** The provider identity Better Auth is about to store, still carrying its random suffix. */
119-
externalAccountId: string
118+
/** The account row Better Auth is about to insert, its `accountId` still carrying the random suffix. */
119+
account: {
120+
userId: string
121+
providerId: string
122+
accountId: string
123+
accessToken?: string | null
124+
refreshToken?: string | null
125+
idToken?: string | null
126+
accessTokenExpiresAt?: Date | null
127+
refreshTokenExpiresAt?: Date | null
128+
scope?: string | null
129+
}
120130
oauthState: OAuthStateWithCallbackUrl | null | undefined
121131
}
122132

@@ -130,14 +140,20 @@ interface CredentialReconnectConflictParams {
130140
* workspace would leave two credentials on one account. When it would, the
131141
* draft is discarded and this returns where to send the user: the page the
132142
* reconnect started from, carrying the reason as the `error` and
133-
* `error_description` the OAuth return handlers already read. Returning before
134-
* the insert leaves every credential and account exactly as it was.
143+
* `error_description` the OAuth return handlers already read.
144+
*
145+
* Returning before the insert leaves every credential bound to the account it
146+
* had. The grant itself is still real, so its tokens replace the ones on the
147+
* identity's existing rows, the same tokens those credentials would have
148+
* inherited from the new row, and a provider that rotates on reauthorization
149+
* cannot strand them on a dead chain.
135150
*/
136-
export async function findCredentialReconnectConflictRedirect(
151+
export async function refuseConflictingCredentialReconnect(
137152
params: CredentialReconnectConflictParams
138153
): Promise<string | null> {
139-
const { draftId, userId, providerId, externalAccountId, oauthState } = params
140-
const stableIdentity = stableExternalAccountId(externalAccountId)
154+
const { draftId, account, oauthState } = params
155+
const { userId, providerId } = account
156+
const stableIdentity = stableExternalAccountId(account.accountId)
141157
if (!stableIdentity) return null
142158

143159
const [draft] = await db
@@ -181,6 +197,22 @@ export async function findCredentialReconnectConflictRedirect(
181197
})
182198
if (!conflicting) return null
183199

200+
await db
201+
.update(schema.account)
202+
.set({
203+
...filterUndefined({
204+
accessToken: account.accessToken ?? undefined,
205+
refreshToken: account.refreshToken ?? undefined,
206+
idToken: account.idToken ?? undefined,
207+
accessTokenExpiresAt: account.accessTokenExpiresAt ?? undefined,
208+
refreshTokenExpiresAt: account.refreshTokenExpiresAt ?? undefined,
209+
scope: account.scope ?? undefined,
210+
}),
211+
updatedAt: new Date(),
212+
})
213+
.where(inArray(schema.account.id, identityAccountIds))
214+
await Promise.all(identityAccountIds.map((id) => clearOAuthRefreshFailure(id)))
215+
184216
await db
185217
.delete(schema.pendingCredentialDraft)
186218
.where(eq(schema.pendingCredentialDraft.id, draftId))

0 commit comments

Comments
 (0)