diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index 12feb6395e8..a59b5eff940 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -249,14 +249,17 @@ describe('createHandoffManager', () => { const manager = createHandoffManager(deps, makeCallbacks()) await manager.beginConnect('google-email', { workspaceId: 'workspace-1', + draftId: 'draft-1', chatAttemptId: 'attempt-1', }) - const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( - 'state' - ) as string + const landing = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]) + const state = landing.searchParams.get('state') as string + + expect(landing.searchParams.get('draftId')).toBe('draft-1') expect(manager.consumeConnect(state)).toEqual({ workspaceId: 'workspace-1', + draftId: 'draft-1', chatAttemptId: 'attempt-1', }) expect(manager.consumeConnect(state)).toBeNull() diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index dc5d15b52e3..2f2e95583b5 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -67,6 +67,7 @@ export interface HandoffManagerDeps { export interface ConnectScope { workspaceId?: string credentialId?: string + draftId?: string chatAttemptId?: string } @@ -292,6 +293,7 @@ export function createHandoffManager( ...(userId ? { user: userId } : {}), ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), + ...(scope.draftId ? { draftId: scope.draftId } : {}), }, scope ) diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index a23f83b4745..60f5ddc6ea5 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -335,21 +335,24 @@ describe('registerIpcHandlers', () => { expect(await handler?.(appEvent, 'slack')).toBe(true) expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', {}) - // Chip-initiated connects carry workspace/credential scope; malformed + // Connects carry workspace/credential or exact-draft scope; malformed // scopes (wrong types, unsafe ids) are rejected before the handoff. expect( await handler?.(appEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1', + draftId: 'draft_1', chatAttemptId: 'attempt_1', }) ).toBe(true) expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', { workspaceId: 'ws1', credentialId: 'cred_1', + draftId: 'draft_1', chatAttemptId: 'attempt_1', }) expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) + expect(await handler?.(appEvent, 'slack', { draftId: '../wrong' })).toBe(false) expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) }) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 4b4b48bb7d8..0ad0a1b942f 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -95,6 +95,7 @@ function parseDesktopScope(raw: unknown): string | null { export interface OAuthConnectScope { workspaceId?: string credentialId?: string + draftId?: string chatAttemptId?: string } @@ -110,9 +111,10 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi if (typeof raw !== 'object') { return undefined } - const { workspaceId, credentialId, chatAttemptId } = raw as { + const { workspaceId, credentialId, draftId, chatAttemptId } = raw as { workspaceId?: unknown credentialId?: unknown + draftId?: unknown chatAttemptId?: unknown } if ( @@ -127,6 +129,9 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi ) { return undefined } + if (draftId !== undefined && (typeof draftId !== 'string' || !ID_PATTERN.test(draftId))) { + return undefined + } if ( chatAttemptId !== undefined && (typeof chatAttemptId !== 'string' || !ID_PATTERN.test(chatAttemptId)) @@ -136,6 +141,7 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi return { ...(workspaceId !== undefined ? { workspaceId } : {}), ...(credentialId !== undefined ? { credentialId } : {}), + ...(draftId !== undefined ? { draftId } : {}), ...(chatAttemptId !== undefined ? { chatAttemptId } : {}), } } diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index f9f1f616ac6..3a1f4beb350 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -11,7 +11,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' -import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-processor' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' const logger = createLogger('OAuth2Authorize') diff --git a/apps/sim/app/desktop/connect/page.tsx b/apps/sim/app/desktop/connect/page.tsx index 8207e2c3acf..4e473134b6b 100644 --- a/apps/sim/app/desktop/connect/page.tsx +++ b/apps/sim/app/desktop/connect/page.tsx @@ -50,8 +50,16 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec const port = parseLoopbackPort(typeof params.port === 'string' ? params.port : '') const workspaceId = isValidOpaqueId(params.workspaceId) ? params.workspaceId : undefined const credentialId = isValidOpaqueId(params.credentialId) ? params.credentialId : undefined + const draftId = isValidOpaqueId(params.draftId) ? params.draftId : undefined const expectedUserId = isValidOpaqueId(params.user) ? params.user : undefined - if (!isValidOAuthProviderId(providerId) || !isValidHandoffState(state) || port === null) { + const hasInvalidDraftId = params.draftId !== undefined && draftId === undefined + if ( + !isValidOAuthProviderId(providerId) || + !isValidHandoffState(state) || + port === null || + hasInvalidDraftId || + (workspaceId !== undefined && draftId !== undefined) + ) { return } @@ -65,6 +73,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec buildDesktopConnectPath(providerId, state, port, { workspaceId, credentialId, + draftId, user: expectedUserId, }) )}` @@ -86,6 +95,7 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec returnTo={buildDesktopConnectPath(providerId, state, port, { workspaceId, credentialId, + draftId, user: expectedUserId, })} /> @@ -113,6 +123,9 @@ export default async function DesktopConnectPage({ searchParams }: DesktopConnec } return ( - + ) } diff --git a/apps/sim/app/desktop/connect/validation.test.ts b/apps/sim/app/desktop/connect/validation.test.ts index 161742ce325..13bdbda1bc9 100644 --- a/apps/sim/app/desktop/connect/validation.test.ts +++ b/apps/sim/app/desktop/connect/validation.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { buildConnectCompletePath, buildConnectLoopbackUrl, @@ -36,19 +37,23 @@ describe('sanitizeOAuthErrorSlug', () => { describe('URL builders', () => { it('buildDesktopConnectPath round-trips provider, state, and port', () => { - const path = buildDesktopConnectPath('google-email', STATE, 49152) + const path = buildDesktopConnectPath('google-email', STATE, 49152, { + draftId: 'draft-1', + }) const url = new URL(path, 'https://sim.ai') expect(url.pathname).toBe('/desktop/connect') expect(url.searchParams.get('provider')).toBe('google-email') expect(url.searchParams.get('state')).toBe(STATE) expect(url.searchParams.get('port')).toBe('49152') + expect(url.searchParams.get('draftId')).toBe('draft-1') }) - it('buildConnectCompletePath carries state and port', () => { - const url = new URL(buildConnectCompletePath(STATE, 49152), 'https://sim.ai') + it('buildConnectCompletePath carries state, port, and the exact credential draft', () => { + const url = new URL(buildConnectCompletePath(STATE, 49152, 'draft-1'), 'https://sim.ai') expect(url.pathname).toBe('/desktop/connect/complete') expect(url.searchParams.get('state')).toBe(STATE) expect(url.searchParams.get('port')).toBe('49152') + expect(url.searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM)).toBe('draft-1') }) it('buildConnectLoopbackUrl targets the 127.0.0.1 connect callback, error optional', () => { diff --git a/apps/sim/app/desktop/connect/validation.ts b/apps/sim/app/desktop/connect/validation.ts index e45d8a6d2f3..4a70467707e 100644 --- a/apps/sim/app/desktop/connect/validation.ts +++ b/apps/sim/app/desktop/connect/validation.ts @@ -1,3 +1,5 @@ +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' + /** * OAuth providerIds are kebab-case service slugs (e.g. "google-email"). The * value is only used to start a better-auth oauth2.link flow, which validates @@ -36,6 +38,7 @@ export function isValidOpaqueId(value: unknown): value is string { export interface ConnectScope { workspaceId?: string credentialId?: string + draftId?: string /** The account the desktop app is signed in as; the flow is pinned to it. */ user?: string } @@ -53,6 +56,7 @@ export function buildDesktopConnectPath( const params = new URLSearchParams({ provider: providerId, state, port: String(port) }) if (scope.workspaceId) params.set('workspaceId', scope.workspaceId) if (scope.credentialId) params.set('credentialId', scope.credentialId) + if (scope.draftId) params.set('draftId', scope.draftId) if (scope.user) params.set('user', scope.user) return `/desktop/connect?${params.toString()}` } @@ -61,8 +65,9 @@ export function buildDesktopConnectPath( * The same-origin path better-auth redirects the browser to after the OAuth * callback — the complete page then bounces to the desktop app's loopback. */ -export function buildConnectCompletePath(state: string, port: number): string { +export function buildConnectCompletePath(state: string, port: number, draftId?: string): string { const params = new URLSearchParams({ state, port: String(port) }) + if (draftId) params.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId) return `/desktop/connect/complete?${params.toString()}` } diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index a5bd8cdf51a..c3624387ae1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -256,6 +256,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { setSubmitError(null) try { let connectorType: string | undefined + let draftId: string | undefined if (isConnect) { const trimmed = displayName.trim() @@ -264,12 +265,13 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { return } - await createDraft.mutateAsync({ + const draft = await createDraft.mutateAsync({ workspaceId, providerId, displayName: trimmed, description: description.trim() || undefined, }) + draftId = draft.draftId const preCount = credentials.filter( (c) => c.type === 'oauth' && c.providerId === providerId @@ -279,6 +281,15 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { displayName: trimmed, providerId, preCount, + baselineCredentials: credentials + .filter( + (credential) => credential.type === 'oauth' && credential.providerId === providerId + ) + .map((credential) => ({ + id: credential.id, + accountId: credential.accountId, + updatedAt: credential.updatedAt, + })), workspaceId, requestedAt: Date.now(), } @@ -319,6 +330,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { await connectOAuthService.mutateAsync({ providerId, callbackURL: callbackURL.toString(), + draftId, }) handleClose() } catch (err: unknown) { diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx index 0a0962db7e6..f4da0081688 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx @@ -111,7 +111,7 @@ export function ConnectedCredentialDetail({ const handleReconnectOAuth = async () => { if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return try { - await createDraft.mutateAsync({ + const draft = await createDraft.mutateAsync({ workspaceId, providerId: credential.providerId, displayName: credential.displayName, @@ -135,6 +135,7 @@ export function ConnectedCredentialDetail({ await connectOAuthService.mutateAsync({ providerId: credential.providerId, callbackURL: window.location.href, + draftId: draft.draftId, }) } catch (error: unknown) { toast.error("Couldn't start reconnect", { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index 449e287a438..dc1fadf781a 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -573,6 +573,7 @@ function ConnectorCard({ providerId: providerId!, preCount: credentials?.length ?? 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) } @@ -607,6 +608,7 @@ function ConnectorCard({ providerId: providerId!, preCount: credentials?.length ?? 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 4449886f4a0..a4d21d33df0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -471,6 +471,7 @@ export function CredentialSelector({ providerId: effectiveProviderId, preCount: credentials.filter((c) => c.type !== 'service_account').length, workspaceId, + reconnect: true, requestedAt: Date.now(), }) setShowOAuthModal(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index aa51770bbed..8d6be7b8e21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -251,6 +251,7 @@ export function ToolCredentialSelector({ providerId: effectiveProviderId, preCount: credentials.length, workspaceId, + reconnect: true, requestedAt: Date.now(), }) setShowOAuthModal(true) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2d035136aeb..966b83b2072 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -572,6 +572,7 @@ const WorkflowContent = React.memo( providerId: detail.providerId, preCount: 0, workspaceId, + reconnect: true, requestedAt: Date.now(), }) diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 3f0dceb37a3..13a6e782422 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -106,7 +106,7 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { export function useCreateCredentialDraft() { return useMutation({ mutationFn: async (payload: ContractBodyInput) => { - await requestJson(createCredentialDraftContract, { body: payload }) + return requestJson(createCredentialDraftContract, { body: payload }) }, }) } diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index a1fa1e29813..119f71f83fd 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -8,6 +8,7 @@ import { type OAuthConnection, } from '@/lib/api/contracts/oauth-connections' import { client } from '@/lib/auth/auth-client' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { getDesktopBridge } from '@/lib/desktop' import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' @@ -138,6 +139,7 @@ export function useOAuthConnections() { interface ConnectServiceParams { providerId: string callbackURL: string + draftId?: string } /** @@ -148,22 +150,25 @@ export function useConnectOAuthService() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ providerId, callbackURL }: ConnectServiceParams) => { + mutationFn: async ({ providerId, callbackURL, draftId }: ConnectServiceParams) => { if (providerId === 'trello') { const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/trello/authorize?returnUrl=${returnUrl}` + const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : '' + window.location.href = `/api/auth/trello/authorize?returnUrl=${returnUrl}${draftQuery}` return { success: true } } if (providerId === 'instagram') { const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/instagram/authorize?returnUrl=${returnUrl}` + const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : '' + window.location.href = `/api/auth/instagram/authorize?returnUrl=${returnUrl}${draftQuery}` return { success: true } } if (providerId === 'shopify') { const returnUrl = encodeURIComponent(callbackURL) - window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}` + const draftQuery = draftId ? `&draftId=${encodeURIComponent(draftId)}` : '' + window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}${draftQuery}` return { success: true } } @@ -175,16 +180,24 @@ export function useConnectOAuthService() { // which refreshes caches and shows the connected toast. const desktopBridge = getDesktopBridge() if (desktopBridge?.beginOAuthConnect) { - const opened = await desktopBridge.beginOAuthConnect(providerId) + const opened = await desktopBridge.beginOAuthConnect( + providerId, + draftId ? { draftId } : undefined + ) if (!opened) { throw new Error('Could not open your browser to connect this account.') } return { success: true } } + const stateCallbackUrl = new URL(callbackURL) + if (draftId) { + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, draftId) + } + await client.oauth2.link({ providerId, - callbackURL, + callbackURL: stateCallbackUrl.toString(), }) return { success: true } diff --git a/apps/sim/hooks/use-oauth-return.test.ts b/apps/sim/hooks/use-oauth-return.test.ts new file mode 100644 index 00000000000..08debcc1a28 --- /dev/null +++ b/apps/sim/hooks/use-oauth-return.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + requestJson: vi.fn(), + requireWorkspaceCredentialListResponse: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + useRouter: vi.fn(), +})) +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) +vi.mock('@/hooks/queries/utils/fetch-workspace-credentials', () => ({ + requireWorkspaceCredentialListResponse: mocks.requireWorkspaceCredentialListResponse, +})) + +import type { OAuthReturnContext } from '@/lib/credentials/client-state' +import { resolveOAuthMessage } from '@/hooks/use-oauth-return' + +const context: OAuthReturnContext = { + origin: 'integrations', + displayName: 'New Gmail', + providerId: 'google-email', + preCount: 1, + baselineCredentials: [ + { + id: 'credential-existing', + accountId: 'account-1', + updatedAt: '2026-08-14T17:00:00.000Z', + }, + ], + workspaceId: 'workspace-1', + requestedAt: Date.now(), +} + +const existingCredential = { + id: 'credential-existing', + workspaceId: 'workspace-1', + type: 'oauth' as const, + displayName: 'Existing Gmail', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-14T18:00:00.000Z', +} + +describe('resolveOAuthMessage', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.requestJson.mockResolvedValue({}) + }) + + it('recognizes an idempotent already-connected account from its reconnect timestamp', async () => { + mocks.requireWorkspaceCredentialListResponse.mockReturnValue([existingCredential]) + + await expect(resolveOAuthMessage(context)).resolves.toEqual({ + kind: 'success', + text: 'This account is already connected as "Existing Gmail".', + }) + }) + + it('keeps explicit update-access flows on the reconnect success path without a baseline', async () => { + await expect( + resolveOAuthMessage({ + ...context, + baselineCredentials: undefined, + reconnect: true, + }) + ).resolves.toEqual({ + kind: 'success', + text: '"New Gmail" reconnected successfully.', + }) + expect(mocks.requestJson).not.toHaveBeenCalled() + }) + + it('does not report success when the credential list is unchanged', async () => { + mocks.requireWorkspaceCredentialListResponse.mockReturnValue([ + { + ...existingCredential, + updatedAt: context.baselineCredentials?.[0].updatedAt, + }, + ]) + + await expect(resolveOAuthMessage(context)).resolves.toEqual({ + kind: 'error', + text: 'We couldn’t verify the "New Gmail" connection. Try again.', + }) + }) +}) diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index d49fd84f7ca..cd595b345b9 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -31,9 +31,14 @@ const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' const CONTEXT_MAX_AGE_MS = 15 * 60 * 1000 -async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { +export interface OAuthResultMessage { + kind: 'success' | 'error' + text: string +} + +export async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { if (ctx.reconnect) { - return `"${ctx.displayName}" reconnected successfully.` + return { kind: 'success', text: `"${ctx.displayName}" reconnected successfully.` } } try { @@ -44,14 +49,48 @@ async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId) if (forProvider.length > ctx.preCount) { - return `"${ctx.displayName}" credential connected successfully.` + return { + kind: 'success', + text: `"${ctx.displayName}" credential connected successfully.`, + } } - const existing = forProvider[0] - return `This account is already connected as "${existing?.displayName || ctx.displayName}".` + const baselineCredentials = new Map( + ctx.baselineCredentials?.map((credential) => [credential.id, credential]) ?? [] + ) + const reauthorizedCredential = forProvider.find((credential) => { + const baseline = baselineCredentials.get(credential.id) + return ( + baseline !== undefined && + (baseline.accountId !== credential.accountId || + (baseline.updatedAt !== undefined && baseline.updatedAt !== credential.updatedAt)) + ) + }) + if (reauthorizedCredential) { + return { + kind: 'success', + text: `This account is already connected as "${reauthorizedCredential.displayName}".`, + } + } } catch { - return `"${ctx.displayName}" credential connected successfully.` + return { + kind: 'error', + text: `We couldn’t verify the "${ctx.displayName}" connection. Try again.`, + } + } + + return { + kind: 'error', + text: `We couldn’t verify the "${ctx.displayName}" connection. Try again.`, + } +} + +function showOAuthResultMessage(result: OAuthResultMessage): void { + if (result.kind === 'success') { + toast.success(result.text) + return } + toast.error(result.text) } function dispatchCredentialUpdate(ctx: { providerId: string; workspaceId: string }) { @@ -171,7 +210,7 @@ export function useOAuthReturnRouter() { consumeOAuthReturnContext() void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() return @@ -213,7 +252,7 @@ export function useOAuthReturnForWorkflow(workflowId: string) { void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() }, [workflowId]) @@ -233,7 +272,7 @@ export function useOAuthReturnForKBConnectors(knowledgeBaseId: string) { void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() }, [knowledgeBaseId]) @@ -277,7 +316,7 @@ export function useDesktopOAuthConnectListener() { if (ctx) { void (async () => { const message = await resolveOAuthMessage(ctx) - toast.success(message) + showOAuthResultMessage(message) dispatchCredentialUpdate(ctx) })() return diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 21942cf9291..b0818ace2f7 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -402,6 +402,7 @@ export const createCredentialDraftContract = defineRouteContract({ mode: 'json', schema: z.object({ success: z.literal(true), + draftId: z.string().min(1), }), }, }) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 915cc5d0cdd..b012ce3732b 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -97,7 +97,8 @@ import { import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { - loadOAuthCredentialDraftBinding, + captureOAuthCredentialDraftBinding, + consumeOAuthCredentialDraftBinding, processCredentialDraft, } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -404,9 +405,22 @@ export const auth = betterAuth({ }, account: { create: { - before: async (account) => { + before: async (account, context) => { const modifiedAccount = { ...account } + if (context?.path.startsWith('/oauth2/callback/')) { + try { + await captureOAuthCredentialDraftBinding(context, () => getOAuthState()) + } catch (error) { + logger.error('[account.create.before] Failed to read OAuth credential draft state', { + userId: account.userId, + providerId: account.providerId, + error, + }) + throw error + } + } + if (account.accessToken && isSalesforceOAuthProviderId(account.providerId)) { const instanceUrl = await fetchSalesforceInstanceUrl( account.providerId, @@ -433,7 +447,7 @@ export const auth = betterAuth({ return { data: modifiedAccount } }, - after: async (account) => { + after: async (account, context) => { /** * Migrate credentials from stale account rows to the newly created one. * @@ -526,18 +540,18 @@ export const auth = betterAuth({ } } - const credentialDraftBinding = await loadOAuthCredentialDraftBinding(() => - getOAuthState() - ) - if (credentialDraftBinding.status === 'unavailable') { - logger.error('[account.create.after] Failed to read OAuth credential draft state', { - userId: account.userId, - providerId: account.providerId, - error: credentialDraftBinding.error, - }) + const isOAuth2Callback = context?.path.startsWith('/oauth2/callback/') === true + const credentialDraftBinding = context + ? consumeOAuthCredentialDraftBinding(context) + : undefined + + if (isOAuth2Callback && !credentialDraftBinding) { + throw new Error( + 'OAuth credential draft binding was not captured before account creation' + ) } - if (credentialDraftBinding.status === 'available') { + if (credentialDraftBinding) { try { await processCredentialDraft({ draftId: credentialDraftBinding.draftId, diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts index 9c3eabbbed8..c89fc68718c 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.test.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -97,7 +97,6 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: undefined, displayName: 'Work Gmail', - displayNameDefinesIntent: true, }) expect(result).toEqual({ authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', @@ -127,7 +126,6 @@ describe('createCredentialConnection', () => { providerId: 'google-email', credentialId: 'credential-1', displayName: 'Existing Gmail', - displayNameDefinesIntent: false, }) }) }) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts index 7a0d8491168..df4e423add7 100644 --- a/apps/sim/lib/credentials/application/create-credential-connection.ts +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -53,7 +53,6 @@ export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ providerId: target.providerId, credentialId: target.credentialId, displayName, - displayNameDefinesIntent: input.providerId !== undefined && displayName !== undefined, }) const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) authorizationUrl.searchParams.set('draftId', draft.id) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts index 5eb547fb6b9..38711cdf3b8 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.test.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -59,7 +59,7 @@ describe('saveCredentialDraft', () => { }) it('authorizes workspace access before resolving reconnect credential access', async () => { - await saveCredentialDraft.execute({ + const result = await saveCredentialDraft.execute({ principal, input: { workspaceId: 'workspace-1', @@ -69,6 +69,7 @@ describe('saveCredentialDraft', () => { }, }) + expect(result).toEqual({ success: true, draftId: 'draft-1' }) expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( mocks.getActor.mock.invocationCallOrder[0] ) @@ -77,7 +78,6 @@ describe('saveCredentialDraft', () => { userId: 'user-1', workspaceId: 'workspace-1', credentialId: 'credential-1', - displayNameDefinesIntent: false, }) ) }) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts index d737d81ebf6..cee499eacfb 100644 --- a/apps/sim/lib/credentials/application/save-credential-draft.ts +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -53,15 +53,14 @@ export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ } }, async execute({ principal, input }) { - await createConnectDraft({ + const draft = await createConnectDraft({ userId: requirePrincipalSubjectUserId(principal), workspaceId: input.workspaceId, providerId: input.providerId, displayName: input.displayName, description: input.description, credentialId: input.credentialId, - displayNameDefinesIntent: input.credentialId === undefined, }) - return { success: true as const } + return { success: true as const, draftId: draft.id } }, }) diff --git a/apps/sim/lib/credentials/client-state.ts b/apps/sim/lib/credentials/client-state.ts index c1e6e3b6fee..c3963cc59e9 100644 --- a/apps/sim/lib/credentials/client-state.ts +++ b/apps/sim/lib/credentials/client-state.ts @@ -52,6 +52,11 @@ interface OAuthReturnBase { displayName: string providerId: string preCount: number + baselineCredentials?: Array<{ + id: string + accountId: string | null + updatedAt?: string + }> workspaceId: string reconnect?: boolean requestedAt: number diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts index 539a7cac8c5..0ce2dccee72 100644 --- a/apps/sim/lib/credentials/connect-draft.test.ts +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGenerateId } = vi.hoisted(() => ({ @@ -19,16 +19,15 @@ describe('createConnectDraft', () => { mockGenerateId.mockReturnValue('new-draft-id') }) - it('refreshes the expiry without changing an active connection intent', async () => { + it('supersedes an active connection intent with a new exact draft id', async () => { const expiresAt = new Date('2026-08-13T20:15:00.000Z') - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'new-draft-id', expiresAt }]) const result = await createConnectDraft({ userId: 'user-1', workspaceId: 'workspace-1', providerId: 'google-email', displayName: 'Work Gmail', - displayNameDefinesIntent: true, }) expect(dbChainMockFns.values).toHaveBeenCalledWith( @@ -37,16 +36,20 @@ describe('createConnectDraft', () => { const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as | { set?: Record; setWhere?: unknown } | undefined - expect(conflict?.set).not.toHaveProperty('id') - expect(conflict?.set).not.toHaveProperty('displayName') - expect(conflict?.set).not.toHaveProperty('credentialId') - expect(conflict?.setWhere).toBeDefined() - expect(result).toEqual({ id: 'active-draft-id', expiresAt }) + expect(conflict?.set).toEqual( + expect.objectContaining({ + id: 'new-draft-id', + displayName: 'Work Gmail', + credentialId: null, + }) + ) + expect(conflict?.setWhere).toBeUndefined() + expect(result).toEqual({ id: 'new-draft-id', expiresAt }) }) - it('refreshes a reconnect target when its mutable display name changes', async () => { + it('supersedes a reconnect target when its mutable display name changes', async () => { const expiresAt = new Date('2026-08-13T20:15:00.000Z') - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'new-draft-id', expiresAt }]) await expect( createConnectDraft({ @@ -56,15 +59,20 @@ describe('createConnectDraft', () => { credentialId: 'credential-1', displayName: 'Renamed Gmail', }) - ).resolves.toEqual({ id: 'active-draft-id', expiresAt }) + ).resolves.toEqual({ id: 'new-draft-id', expiresAt }) - expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith( - schemaMock.pendingCredentialDraft.displayName, - 'Renamed Gmail' + expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ + id: 'new-draft-id', + displayName: 'Renamed Gmail', + credentialId: 'credential-1', + }), + }) ) }) - it('fails fast when an active draft has a different connection intent', async () => { + it('fails when the draft upsert does not return a row', async () => { dbChainMockFns.returning.mockResolvedValueOnce([]) await expect( @@ -75,9 +83,6 @@ describe('createConnectDraft', () => { credentialId: 'credential-1', displayName: 'Existing Gmail', }) - ).rejects.toMatchObject({ - code: 'conflict', - message: 'A different OAuth connection flow is already active for this provider', - }) + ).rejects.toThrow('Failed to create OAuth credential draft') }) }) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index bda705b4744..97f0f6ef744 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,8 +2,7 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, gt, isNull, lt } from 'drizzle-orm' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { and, eq, gt, lt } from 'drizzle-orm' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' @@ -30,8 +29,6 @@ export async function createConnectDraft(params: { /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string description?: string - /** Whether an explicitly requested name distinguishes this new-connection intent. */ - displayNameDefinesIntent?: boolean }): Promise { const { userId, workspaceId, providerId, credentialId } = params @@ -70,12 +67,6 @@ export async function createConnectDraft(params: { and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) const id = generateId() - const sameTarget = credentialId - ? eq(pendingCredentialDraft.credentialId, credentialId) - : isNull(pendingCredentialDraft.credentialId) - const sameIntent = params.displayNameDefinesIntent - ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName)) - : sameTarget const [draft] = await db .insert(pendingCredentialDraft) .values({ @@ -95,16 +86,24 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - set: { expiresAt, createdAt: now }, - setWhere: sameIntent, + /** + * A new launch supersedes an abandoned or failed launch for this provider. + * Rotating the primary key invalidates the earlier callback's exact draft + * binding, so only the newest OAuth flow can materialize its intent. + */ + set: { + id, + displayName, + description: params.description?.trim() || null, + credentialId: credentialId ?? null, + expiresAt, + createdAt: now, + }, }) .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) if (!draft) { - throw new OrchestrationError( - 'conflict', - 'A different OAuth connection flow is already active for this provider' - ) + throw new Error('Failed to create OAuth credential draft') } logger.info('Created OAuth connect credential draft', { diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts index ff7525a35f9..26a244bf02c 100644 --- a/apps/sim/lib/credentials/draft-constants.ts +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -1,2 +1,3 @@ export const CREDENTIAL_DRAFT_TTL_MS = 15 * 60 * 1000 export const CREDENTIAL_DRAFT_TTL_SECONDS = CREDENTIAL_DRAFT_TTL_MS / 1000 +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts index bf2e88a15a9..5737c26ac23 100644 --- a/apps/sim/lib/credentials/draft-hooks.test.ts +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { auditMock, auditMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + auditMock, + auditMockFns, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -12,7 +19,75 @@ vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/oauth/terminal-errors', () => ({ clearDeadFlag: mocks.clearDeadFlag })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -import { handleReconnectCredential } from '@/lib/credentials/draft-hooks' +import { + handleCreateCredentialFromDraft, + handleReconnectCredential, +} from '@/lib/credentials/draft-hooks' + +describe('handleCreateCredentialFromDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('treats a wrapped workspace-account conflict as an existing connection', async () => { + const now = new Date('2026-08-14T18:00:00.000Z') + const cause = Object.assign(new Error('duplicate key'), { + code: '23505', + constraint_name: 'credential_workspace_account_unique', + }) + dbChainMockFns.values.mockRejectedValueOnce(new Error('Failed query', { cause })) + queueTableRows(schemaMock.credential, [ + { id: 'credential-existing', displayName: 'Existing Gmail' }, + ]) + + await handleCreateCredentialFromDraft({ + draft: { + workspaceId: 'workspace-1', + displayName: 'New Gmail', + description: null, + }, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now, + }) + + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credential) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: now }) + expect(mocks.clearDeadFlag).toHaveBeenCalledWith('account-1') + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'credential.reconnected', + resourceId: 'credential-existing', + resourceName: 'Existing Gmail', + }) + ) + }) + + it('rethrows a different unique constraint failure', async () => { + const cause = Object.assign(new Error('duplicate key'), { + code: '23505', + constraint_name: 'credential_display_name_unique', + }) + const wrapped = new Error('Failed query', { cause }) + dbChainMockFns.values.mockRejectedValueOnce(wrapped) + + await expect( + handleCreateCredentialFromDraft({ + draft: { + workspaceId: 'workspace-1', + displayName: 'New Gmail', + description: null, + }, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + ).rejects.toBe(wrapped) + }) +}) describe('handleReconnectCredential', () => { beforeEach(() => { diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index cec4f1e607a..79852f7824a 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' @@ -36,65 +37,103 @@ export async function handleCreateCredentialFromDraft(params: { createdAt: now, updatedAt: now, }) + } catch (insertError: unknown) { + if ( + getPostgresErrorCode(insertError) !== '23505' || + getPostgresConstraintName(insertError) !== 'credential_workspace_account_unique' + ) { + throw insertError + } - await db.insert(schema.credentialMember).values({ - id: generateId(), - credentialId, - userId, - role: 'admin', - status: 'active', - joinedAt: now, - invitedBy: userId, - createdAt: now, - updatedAt: now, - }) + const [existingCredential] = await db + .select({ id: schema.credential.id, displayName: schema.credential.displayName }) + .from(schema.credential) + .where( + and( + eq(schema.credential.workspaceId, draft.workspaceId), + eq(schema.credential.accountId, accountId) + ) + ) + .limit(1) - logger.info('Created credential from draft', { - credentialId, - displayName: draft.displayName, + if (!existingCredential) { + throw new Error( + `Credential account conflict was reported without an existing credential for account ${accountId}` + ) + } + + logger.info('OAuth account is already connected to this workspace', { + credentialId: existingCredential.id, + displayName: existingCredential.displayName, providerId, accountId, }) + await db + .update(schema.credential) + .set({ updatedAt: now }) + .where(eq(schema.credential.id, existingCredential.id)) + await clearDeadFlag(accountId) recordAudit({ workspaceId: draft.workspaceId, actorId: userId, - action: AuditAction.CREDENTIAL_CREATED, + action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: draft.displayName, - description: `Created OAuth credential "${draft.displayName}"`, - metadata: { providerId, accountId }, + resourceId: existingCredential.id, + resourceName: existingCredential.displayName, + description: `Reconnected OAuth credential "${existingCredential.displayName}"`, + metadata: { accountId }, }) + return + } - captureServerEvent( - userId, - 'credential_connected', - { - credential_type: 'oauth', - provider_id: providerId, - workspace_id: draft.workspaceId, - }, - { - groups: { workspace: draft.workspaceId }, - setOnce: { first_credential_connected_at: now.toISOString() }, - } - ) - } catch (insertError: unknown) { - const code = - insertError && typeof insertError === 'object' && 'code' in insertError - ? (insertError as { code: string }).code - : undefined - if (code !== '23505') { - throw insertError + await db.insert(schema.credentialMember).values({ + id: generateId(), + credentialId, + userId, + role: 'admin', + status: 'active', + joinedAt: now, + invitedBy: userId, + createdAt: now, + updatedAt: now, + }) + + logger.info('Created credential from draft', { + credentialId, + displayName: draft.displayName, + providerId, + accountId, + }) + + await clearDeadFlag(accountId) + + recordAudit({ + workspaceId: draft.workspaceId, + actorId: userId, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: draft.displayName, + description: `Created OAuth credential "${draft.displayName}"`, + metadata: { providerId, accountId }, + }) + + captureServerEvent( + userId, + 'credential_connected', + { + credential_type: 'oauth', + provider_id: providerId, + workspace_id: draft.workspaceId, + }, + { + groups: { workspace: draft.workspaceId }, + setOnce: { first_credential_connected_at: now.toISOString() }, } - logger.info('Credential already exists, skipping draft', { - providerId, - accountId, - }) - } + ) } /** diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts index 2cf5c13014d..00401d165d4 100644 --- a/apps/sim/lib/credentials/draft-processor.test.ts +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -21,6 +21,8 @@ vi.mock('@/lib/credentials/draft-hooks', () => ({ })) import { + captureOAuthCredentialDraftBinding, + consumeOAuthCredentialDraftBinding, loadOAuthCredentialDraftBinding, parseCredentialDraftIdFromCallbackUrl, processCredentialDraft, @@ -150,3 +152,31 @@ describe('loadOAuthCredentialDraftBinding', () => { expect(binding.status).toBe('unavailable') }) }) + +describe('OAuth credential draft account hook binding', () => { + it('carries an exact binding from the account before-hook to the deferred after-hook', async () => { + const context = {} + + await captureOAuthCredentialDraftBinding(context, async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + + expect(consumeOAuthCredentialDraftBinding(context)).toEqual({ + status: 'available', + draftId: 'draft-exact', + }) + expect(consumeOAuthCredentialDraftBinding(context)).toBeUndefined() + }) + + it('fails before account creation when OAuth state cannot be captured', async () => { + const context = {} + const stateError = new Error('OAuth state is unavailable') + + await expect( + captureOAuthCredentialDraftBinding(context, async () => { + throw stateError + }) + ).rejects.toBe(stateError) + expect(consumeOAuthCredentialDraftBinding(context)).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index 0b60ccac106..c375dd8d48c 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, sql } from 'drizzle-orm' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -9,16 +10,21 @@ import { const logger = createLogger('CredentialDraftProcessor') -export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' - interface OAuthStateWithCallbackUrl { callbackURL?: unknown } -type OAuthCredentialDraftBinding = +export type OAuthCredentialDraftBinding = | { status: 'available'; draftId?: string } | { status: 'unavailable'; error: unknown } +type AvailableOAuthCredentialDraftBinding = Extract< + OAuthCredentialDraftBinding, + { status: 'available' } +> + +const oauthCredentialDraftBindings = new WeakMap() + /** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { if (callbackUrl === undefined) return undefined @@ -43,6 +49,25 @@ export async function loadOAuthCredentialDraftBinding( } } +/** Captures request-scoped OAuth state before Better Auth defers its account after-hook. */ +export async function captureOAuthCredentialDraftBinding( + context: object, + loadOAuthState: () => Promise +): Promise { + const binding = await loadOAuthCredentialDraftBinding(loadOAuthState) + if (binding.status === 'unavailable') throw binding.error + oauthCredentialDraftBindings.set(context, binding) +} + +/** Consumes the binding captured for the same Better Auth account-hook context. */ +export function consumeOAuthCredentialDraftBinding( + context: object +): AvailableOAuthCredentialDraftBinding | undefined { + const binding = oauthCredentialDraftBindings.get(context) + oauthCredentialDraftBindings.delete(context) + return binding +} + interface ProcessCredentialDraftParams { draftId?: string userId: string diff --git a/packages/desktop-bridge/src/index.ts b/packages/desktop-bridge/src/index.ts index e32ba6ac21a..c49a64ed353 100644 --- a/packages/desktop-bridge/src/index.ts +++ b/packages/desktop-bridge/src/index.ts @@ -641,11 +641,12 @@ export interface DesktopOAuthConnectResult { * Optional scope for an OAuth connect handoff. Chip-initiated connects carry * the workspace (the browser flow creates the workspace connect draft * server-side) and, for reconnects, the credential to rebind. Modal-initiated - * connects omit both — the app already created the draft. + * connects carry the exact draft the app already created. */ export interface DesktopOAuthConnectScope { workspaceId?: string credentialId?: string + draftId?: string /** Mothership credential-chip attempt to echo on desktop completion. */ chatAttemptId?: string }