Skip to content

Commit 5d9752d

Browse files
fix(mothership): connect integrations from chat without state_mismatch (#4848)
* fix(oauth): skipStateCookieCheck flag change * browser initated solution * fix draft timing issue
1 parent 3f3efc9 commit 5d9752d

4 files changed

Lines changed: 184 additions & 65 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { db } from '@sim/db'
2+
import { pendingCredentialDraft, user } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { generateId } from '@sim/utils/id'
5+
import { and, eq, lt } from 'drizzle-orm'
6+
import { type NextRequest, NextResponse } from 'next/server'
7+
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
8+
import { parseRequest } from '@/lib/api/server'
9+
import { auth, getSession } from '@/lib/auth/auth'
10+
import { getBaseUrl } from '@/lib/core/utils/urls'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { getAllOAuthServices } from '@/lib/oauth/utils'
13+
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
14+
15+
const logger = createLogger('OAuth2Authorize')
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
const DRAFT_TTL_MS = 15 * 60 * 1000
20+
21+
/**
22+
* Creates the pending credential draft at click time so its TTL starts when the
23+
* user actually initiates the connect. Better Auth's `account.create.after` hook
24+
* consumes this draft to materialize the real credential after the OAuth
25+
* callback; starting the clock here guarantees the draft outlives the (≤5 min)
26+
* OAuth round-trip rather than expiring mid-flow and silently producing no
27+
* credential.
28+
*/
29+
async function createConnectDraft(params: {
30+
userId: string
31+
workspaceId: string
32+
providerId: string
33+
}): Promise<void> {
34+
const { userId, workspaceId, providerId } = params
35+
36+
const service = getAllOAuthServices().find((s) => s.providerId === providerId)
37+
const serviceName = service?.name ?? providerId
38+
39+
let displayName = serviceName
40+
try {
41+
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
42+
if (row?.name) {
43+
displayName = `${row.name}'s ${serviceName}`
44+
}
45+
} catch {
46+
// Fall back to service name only
47+
}
48+
49+
const now = new Date()
50+
const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS)
51+
await db
52+
.delete(pendingCredentialDraft)
53+
.where(
54+
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
55+
)
56+
await db
57+
.insert(pendingCredentialDraft)
58+
.values({
59+
id: generateId(),
60+
userId,
61+
workspaceId,
62+
providerId,
63+
displayName,
64+
expiresAt,
65+
createdAt: now,
66+
})
67+
.onConflictDoUpdate({
68+
target: [
69+
pendingCredentialDraft.userId,
70+
pendingCredentialDraft.providerId,
71+
pendingCredentialDraft.workspaceId,
72+
],
73+
set: { displayName, expiresAt, createdAt: now },
74+
})
75+
76+
logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId })
77+
}
78+
79+
/**
80+
* Browser-initiated entrypoint for linking a generic OAuth2 account.
81+
*/
82+
export const GET = withRouteHandler(async (request: NextRequest) => {
83+
const baseUrl = getBaseUrl()
84+
85+
const session = await getSession()
86+
if (!session?.user?.id) {
87+
const loginUrl = new URL('/login', baseUrl)
88+
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search)
89+
return NextResponse.redirect(loginUrl.toString())
90+
}
91+
const userId = session.user.id
92+
93+
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
94+
if (!parsed.success) return parsed.response
95+
const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query
96+
97+
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
98+
? requestedCallback
99+
: `${baseUrl}/workspace`
100+
101+
try {
102+
const access = await checkWorkspaceAccess(workspaceId, userId)
103+
if (!access.canWrite) {
104+
logger.warn('Workspace write access denied for OAuth2 authorize', {
105+
userId,
106+
workspaceId,
107+
providerId,
108+
})
109+
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
110+
}
111+
112+
// Create the draft before initiating the link so it is guaranteed to exist
113+
// (and freshly clocked) when the OAuth callback's `account.create.after`
114+
// hook runs. If this throws, we never start the OAuth flow.
115+
await createConnectDraft({ userId, workspaceId, providerId })
116+
117+
const linkResponse = await auth.api.oAuth2LinkAccount({
118+
body: { providerId, callbackURL },
119+
headers: request.headers,
120+
asResponse: true,
121+
})
122+
123+
const payload = (await linkResponse.json().catch(() => null)) as { url?: string } | null
124+
if (!linkResponse.ok || !payload?.url) {
125+
logger.error('oAuth2LinkAccount did not return an authorization URL', {
126+
providerId,
127+
status: linkResponse.status,
128+
})
129+
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
130+
}
131+
132+
const response = NextResponse.redirect(payload.url)
133+
// Forward the signed `state` cookie Better Auth set so it lands in the user's
134+
// browser and is present when the provider redirects back to the callback.
135+
const linkHeaders = linkResponse.headers as Headers & {
136+
getSetCookie?: () => string[]
137+
}
138+
for (const cookie of linkHeaders.getSetCookie?.() ?? []) {
139+
response.headers.append('set-cookie', cookie)
140+
}
141+
return response
142+
} catch (error) {
143+
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
144+
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
145+
}
146+
})

apps/sim/lib/api/contracts/oauth-connections.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod'
2+
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
23
import type {
34
ContractBody,
45
ContractBodyInput,
@@ -190,6 +191,19 @@ export const trelloCallbackContract = defineRouteContract({
190191
response: { mode: 'text' },
191192
})
192193

194+
export const authorizeOAuth2QuerySchema = z.object({
195+
providerId: z.string().min(1, 'providerId is required'),
196+
workspaceId: workspaceIdSchema,
197+
callbackURL: z.string().min(1).optional(),
198+
})
199+
200+
export const authorizeOAuth2Contract = defineRouteContract({
201+
method: 'GET',
202+
path: '/api/auth/oauth2/authorize',
203+
query: authorizeOAuth2QuerySchema,
204+
response: { mode: 'redirect' },
205+
})
206+
193207
export type StoreTrelloTokenBody = ContractBody<typeof storeTrelloTokenContract>
194208
export type StoreTrelloTokenBodyInput = ContractBodyInput<typeof storeTrelloTokenContract>
195209
export type StoreTrelloTokenResponse = ContractJsonResponse<typeof storeTrelloTokenContract>

apps/sim/lib/copilot/tools/handlers/oauth.ts

Lines changed: 22 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import { db } from '@sim/db'
2-
import { pendingCredentialDraft, user } from '@sim/db/schema'
31
import { toError } from '@sim/utils/errors'
4-
import { generateId } from '@sim/utils/id'
5-
import { and, eq, lt } from 'drizzle-orm'
62
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
73
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
84
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -20,7 +16,6 @@ export async function executeOAuthGetAuthLink(
2016
}
2117
await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write')
2218
const result = await generateOAuthLink(
23-
context.userId,
2419
context.workspaceId,
2520
context.workflowId,
2621
context.chatId,
@@ -69,14 +64,16 @@ export async function executeOAuthRequestAccess(
6964
}
7065

7166
/**
72-
* Resolves a human-friendly provider name to a providerId and generates the
73-
* actual OAuth authorization URL via Better Auth's server-side API.
67+
* Resolves a human-friendly provider name to a providerId and returns a
68+
* browser-initiated authorize URL the user opens to connect the service.
7469
*
75-
* Steps: resolve provider → create credential draft → look up user session →
76-
* call auth.api.oAuth2LinkAccount → return the real authorization URL.
70+
* Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL.
71+
* That endpoint (not this server-side handler) creates the credential draft and
72+
* calls Better Auth, so the draft's TTL starts at click and the signed `state`
73+
* cookie is planted in the user's browser and the OAuth callback's state check
74+
* passes.
7775
*/
7876
async function generateOAuthLink(
79-
userId: string,
8077
workspaceId: string | undefined,
8178
workflowId: string | undefined,
8279
chatId: string | undefined,
@@ -127,58 +124,20 @@ async function generateOAuthLink(
127124
}
128125
}
129126

130-
let displayName = serviceName
131-
try {
132-
const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
133-
if (row?.name) {
134-
displayName = `${row.name}'s ${serviceName}`
135-
}
136-
} catch {
137-
// Fall back to service name only
138-
}
139-
140-
const now = new Date()
141-
await db
142-
.delete(pendingCredentialDraft)
143-
.where(
144-
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
145-
)
146-
await db
147-
.insert(pendingCredentialDraft)
148-
.values({
149-
id: generateId(),
150-
userId,
151-
workspaceId,
152-
providerId,
153-
displayName,
154-
expiresAt: new Date(now.getTime() + 15 * 60 * 1000),
155-
createdAt: now,
156-
})
157-
.onConflictDoUpdate({
158-
target: [
159-
pendingCredentialDraft.userId,
160-
pendingCredentialDraft.providerId,
161-
pendingCredentialDraft.workspaceId,
162-
],
163-
set: {
164-
displayName,
165-
expiresAt: new Date(now.getTime() + 15 * 60 * 1000),
166-
createdAt: now,
167-
},
168-
})
169-
170-
const { auth } = await import('@/lib/auth/auth')
171-
const { headers: getHeaders } = await import('next/headers')
172-
const reqHeaders = await getHeaders()
173-
174-
const data = (await auth.api.oAuth2LinkAccount({
175-
body: { providerId, callbackURL },
176-
headers: reqHeaders,
177-
})) as { url?: string; redirect?: boolean }
178-
179-
if (!data?.url) {
180-
throw new Error('oAuth2LinkAccount did not return an authorization URL')
181-
}
127+
// Hand back a browser-initiated authorize URL rather than calling
128+
// oAuth2LinkAccount here. Generating the link server-side would set Better
129+
// Auth's signed `state` cookie on this server-to-server response instead of the
130+
// user's browser, so the OAuth callback would fail with `state_mismatch`. The
131+
// authorize endpoint runs the link inside the user's browser, planting the
132+
// cookie correctly while keeping the callback's state check enabled.
133+
//
134+
// The pending credential draft is created by that authorize endpoint at click
135+
// time (not here), so the draft's TTL starts when the user actually initiates
136+
// the connect and reliably outlives the OAuth round-trip.
137+
const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`)
138+
authorizeUrl.searchParams.set('providerId', providerId)
139+
authorizeUrl.searchParams.set('workspaceId', workspaceId)
140+
authorizeUrl.searchParams.set('callbackURL', callbackURL)
182141

183-
return { url: data.url, providerId, serviceName }
142+
return { url: authorizeUrl.toString(), providerId, serviceName }
184143
}

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 761,
13-
zodRoutes: 761,
12+
totalRoutes: 762,
13+
zodRoutes: 762,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)