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
22 changes: 22 additions & 0 deletions apps/sim/hooks/use-oauth-return.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,28 @@ describe('resolveOAuthCallbackError', () => {
text: 'The "New Gmail" connection didn’t finish. Try again.',
})
})

it('names the credential that already uses the account a reconnect chose', () => {
const reason = 'That Google account is already connected in this workspace as "Personal".'
const url = new URL('https://sim.ai/workspace/workspace-1/integrations')
url.searchParams.set('error', 'credential_account_conflict')
url.searchParams.set('error_description', reason)

expect(resolveOAuthCallbackError(url.toString(), context)).toEqual({
kind: 'error',
text: reason,
})
})

it('keeps a provider-supplied error description out of the message', () => {
const url = new URL('https://sim.ai/workspace/workspace-1/integrations')
url.searchParams.set('error', 'access_denied')
url.searchParams.set('error_description', 'Visit example.test to restore access')

expect(resolveOAuthCallbackError(url.toString(), context)?.text).toBe(
'The "New Gmail" connection didn’t finish. Try again.'
)
})
})

describe('buildKnowledgeBaseOAuthReturnUrl', () => {
Expand Down
26 changes: 22 additions & 4 deletions apps/sim/hooks/use-oauth-return.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type OAuthReturnContext,
readOAuthReturnContext,
} from '@/lib/credentials/client-state'
import { OAUTH_CREDENTIAL_ACCOUNT_CONFLICT_ERROR } from '@/lib/credentials/draft-constants'
import {
hasOAuthCredentialChanged,
OAUTH_CHAT_ATTEMPT_MAX_AGE_MS,
Expand Down Expand Up @@ -124,13 +125,26 @@ export function resolveOAuthCallbackError(
callbackUrl: string,
ctx: Pick<OAuthReturnContext, 'displayName'>
): OAuthResultMessage | null {
if (!new URL(callbackUrl).searchParams.has('error')) return null
const params = new URL(callbackUrl).searchParams
if (!params.has('error')) return null
return {
kind: 'error',
text: `The "${ctx.displayName}" connection didn’t finish. Try again.`,
text:
accountConflictDescription(params) ??
`The "${ctx.displayName}" connection didn’t finish. Try again.`,
}
}

/**
* The server-written reason a reconnect was refused because the chosen account
* already backs another credential. Other `error_description` values come from
* the provider and stay hidden behind the generic message.
*/
function accountConflictDescription(params: URLSearchParams): string | null {
if (params.get('error') !== OAUTH_CREDENTIAL_ACCOUNT_CONFLICT_ERROR) return null
return params.get('error_description') || null
}

function consumeOAuthCallbackError(ctx: OAuthReturnContext): OAuthResultMessage | null {
const result = resolveOAuthCallbackError(window.location.href, ctx)
if (!result) return null
Expand Down Expand Up @@ -221,9 +235,13 @@ async function verifyOAuthChatAttempt(queryClient: QueryClient, attemptId: strin
const attempt = readOAuthChatAttempt(attemptId)
if (!attempt || Date.now() - attempt.requestedAt > OAUTH_CHAT_ATTEMPT_MAX_AGE_MS) return

if (new URL(window.location.href).searchParams.has('error')) {
const callbackParams = new URL(window.location.href).searchParams
if (callbackParams.has('error')) {
setOAuthChatAttemptStatus(attempt.id, 'failed')
toast.error(`The ${attempt.displayName} connection didn’t finish. Try again.`)
toast.error(
accountConflictDescription(callbackParams) ??
`The ${attempt.displayName} connection didn’t finish. Try again.`
)
return
}

Expand Down
137 changes: 85 additions & 52 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { holdAdminRemovalStops, stopAdminRemovedRuns } from '@/lib/auth/admin-removal-stops'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { stableExternalAccountId } from '@/lib/auth/connectors/account-id'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
import {
applyRegistrationGate,
Expand Down Expand Up @@ -142,10 +143,12 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { trustedProxies } from '@/lib/core/utils/request'
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
import { CredentialAccountConflictError } from '@/lib/credentials/draft-hooks'
import {
captureOAuthCredentialDraftBinding,
consumeOAuthCredentialDraftBinding,
processCredentialDraft,
refuseConflictingCredentialReconnect,
} from '@/lib/credentials/draft-processor'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
Expand Down Expand Up @@ -294,6 +297,40 @@ async function fetchSalesforceInstanceUrl(
}
}

/**
* A fresh Slack connect re-issues the installation's rotating token chain,
* invalidating the copies held by sibling account rows for the same team (Slack
* bot tokens are per-installation, not per-grant). Propagates the new chain so
* every sibling is valid again, and clears the installation's dead flag. A
* failure is logged, never thrown: the connect itself already succeeded.
*/
async function propagateFreshSlackConnect(account: {
userId: string
providerId: string
accountId: string
accessToken?: string | null
refreshToken?: string | null
accessTokenExpiresAt?: Date | null
}): Promise<void> {
if (account.providerId !== 'slack' || !account.accessToken) return
try {
const teamId = extractSlackTeamId(account.accountId)
if (!teamId) return
// Clear the dead flag before fanning out: the connect itself proves the
// installation has live tokens, and a fan-out failure must not leave the
// hour-long flag blocking refreshes.
await clearOAuthRefreshDeadFlag(`slack:${teamId}`)
await fanOutSlackTokenChain(teamId, {
accessToken: account.accessToken,
refreshToken: account.refreshToken ?? null,
accessTokenExpiresAt: account.accessTokenExpiresAt ?? null,
})
logger.info('Propagated Slack installation token chain', { userId: account.userId, teamId })
} catch (error) {
logger.error('Failed to propagate Slack token chain', { userId: account.userId, error })
}
}

export const auth = betterAuth({
baseURL: getBaseUrl(),
// Where Better Auth sends OAuth callbacks that fail before the flow state is
Expand Down Expand Up @@ -500,10 +537,12 @@ export const auth = betterAuth({
create: {
before: async (account, context) => {
const modifiedAccount = { ...account }
let reconnectDraftId: string | undefined

if (context?.path.startsWith('/oauth2/callback/')) {
let draftId: string | undefined
try {
await captureOAuthCredentialDraftBinding(context, () => getOAuthState())
draftId = await captureOAuthCredentialDraftBinding(context, () => getOAuthState())
} catch (error) {
logger.error('[account.create.before] Failed to read OAuth credential draft state', {
userId: account.userId,
Expand All @@ -512,6 +551,7 @@ export const auth = betterAuth({
})
throw error
}
reconnectDraftId = draftId
}

if (account.accessToken && isSalesforceOAuthProviderId(account.providerId)) {
Expand All @@ -538,25 +578,42 @@ export const auth = betterAuth({
}
}

/**
* The after-hook runs once the callback has already answered, so a
* reconnect onto an account another credential in the workspace uses
* has to be refused here, before the row exists, to send the user
* back with a reason instead of a 500. The fresh grant still lands
* on the identity's existing rows: the provider may already have
* rotated their tokens away (Slack installations do).
*/
if (context && reconnectDraftId) {
const conflictRedirectUrl = await refuseConflictingCredentialReconnect({
draftId: reconnectDraftId,
account: modifiedAccount,
oauthState: await getOAuthState(),
})
if (conflictRedirectUrl) {
consumeOAuthCredentialDraftBinding(context)
await propagateFreshSlackConnect(modifiedAccount)
throw new APIError('FOUND', undefined, { location: conflictRedirectUrl })
}
}

return { data: modifiedAccount }
},
after: async (account, context) => {
/**
* Migrate credentials from stale account rows to the newly created one.
*
* Each `getUserInfo` in `lib/auth/connectors/providers.ts` appends a
* random UUID to the stable external ID so that Better Auth never
* blocks cross-user connections — keep the two in step. This means
* re-connecting the same external identity creates a new row. We detect
* the stale siblings here by comparing the stable prefix (everything
* before the trailing UUID), migrate any credential FKs to the new row,
* then delete the stale rows.
* Re-connecting the same external identity creates a new row (see
* `stableExternalAccountId`). We detect the stale siblings here by
* comparing the stable prefix, migrate any credential FKs to the new
* row, then delete the stale rows.
*/
try {
const UUID_SUFFIX_RE = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
const stablePrefix = account.accountId.replace(UUID_SUFFIX_RE, '')
const stablePrefix = stableExternalAccountId(account.accountId)

if (stablePrefix && stablePrefix !== account.accountId) {
if (stablePrefix) {
const siblings = await db
.select({ id: schema.account.id, accountId: schema.account.accountId })
.from(schema.account)
Expand All @@ -569,7 +626,7 @@ export const auth = betterAuth({
)

const staleRows = siblings.filter(
(row) => row.accountId.replace(UUID_SUFFIX_RE, '') === stablePrefix
(row) => stableExternalAccountId(row.accountId) === stablePrefix
)

if (staleRows.length > 0) {
Expand Down Expand Up @@ -598,40 +655,7 @@ export const auth = betterAuth({
})
}

/**
* A fresh Slack connect re-issues the installation's rotating token
* chain, invalidating the copies held by sibling account rows for the
* same team (Slack bot tokens are per-installation, not per-grant).
* Propagate the new chain so every sibling is valid again, and clear
* the installation's dead flag.
*/
if (account.providerId === 'slack' && account.accessToken) {
try {
const teamId = extractSlackTeamId(account.accountId)
if (teamId) {
// Clear the dead flag before fanning out: the connect itself
// proves the installation has live tokens, and a fan-out
// failure must not leave the hour-long flag blocking refreshes.
await clearOAuthRefreshDeadFlag(`slack:${teamId}`)
await fanOutSlackTokenChain(teamId, {
accessToken: account.accessToken,
refreshToken: account.refreshToken ?? null,
accessTokenExpiresAt: account.accessTokenExpiresAt ?? null,
})
logger.info('[account.create.after] Propagated Slack installation token chain', {
userId: account.userId,
teamId,
newAccountId: account.id,
})
}
} catch (error) {
logger.error('[account.create.after] Failed to propagate Slack token chain', {
userId: account.userId,
accountId: account.id,
error,
})
}
}
await propagateFreshSlackConnect(account)

const isOAuth2Callback = context?.path.startsWith('/oauth2/callback/') === true
const credentialDraftBinding = context
Expand All @@ -653,12 +677,21 @@ export const auth = betterAuth({
accountId: account.id,
})
} catch (error) {
logger.error('[account.create.after] Failed to process credential draft', {
userId: account.userId,
providerId: account.providerId,
error,
})
if (credentialDraftBinding.draftId) throw error
if (error instanceof CredentialAccountConflictError) {
logger.warn('[account.create.after] Refused a conflicting credential reconnect', {
userId: account.userId,
providerId: account.providerId,
credentialId: error.credentialId,
conflictingCredentialId: error.conflictingCredentialId,
})
} else {
logger.error('[account.create.after] Failed to process credential draft', {
userId: account.userId,
providerId: account.providerId,
error,
})
if (credentialDraftBinding.draftId) throw error
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/auth/connectors/account-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const RANDOM_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/

/**
* Returns the provider's stable identity inside a connector account id.
*
* Each `getUserInfo` in `lib/auth/connectors/providers.ts` appends a random
* UUID to the stable external id so Better Auth never blocks cross-user
* connections, which means every connect of the same identity creates a new
* account row. Rows whose ids share this prefix are the same external identity.
* Returns `null` for an id that carries no such suffix.
*/
export function stableExternalAccountId(accountId: string): string | null {
const stable = accountId.replace(RANDOM_UUID_SUFFIX, '')
return stable && stable !== accountId ? stable : null
}
Loading
Loading