From 76381ff46de5aeebd1df00ebd16b6accf8b358ac Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 2 Jun 2026 11:17:32 -0700 Subject: [PATCH 1/3] fix(oauth): skipStateCookieCheck flag change --- apps/sim/lib/auth/auth.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 40037c938c..f19162f6fb 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -687,6 +687,12 @@ export const auth = betterAuth({ ...SSO_TRUSTED_PROVIDERS, ], }, + // Mothership/headless OAuth links are generated server-side via auth.api.oAuth2LinkAccount, + // so better-auth's signed `state` cookie is set on the server-to-server response and never + // reaches the user's browser. With the database state strategy, the callback would then fail + // with state_security_mismatch (`?error=state_mismatch`). The DB verification record + PKCE + // still bind the flow, so skip the additional browser-cookie check. + skipStateCookieCheck: true, }, socialProviders: { ...(!isGithubAuthDisabled && { From 733656c5fa196dca2e0c102f1ff8f4da2dcc73f3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 2 Jun 2026 11:39:50 -0700 Subject: [PATCH 2/3] browser initated solution --- .../app/api/auth/oauth2/authorize/route.ts | 64 +++++++++++++++++++ .../lib/api/contracts/oauth-connections.ts | 12 ++++ apps/sim/lib/auth/auth.ts | 6 -- apps/sim/lib/copilot/tools/handlers/oauth.ts | 33 +++++----- scripts/check-api-validation-contracts.ts | 4 +- 5 files changed, 94 insertions(+), 25 deletions(-) create mode 100644 apps/sim/app/api/auth/oauth2/authorize/route.ts diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts new file mode 100644 index 0000000000..c7238a5311 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -0,0 +1,64 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { auth, getSession } from '@/lib/auth/auth' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +const logger = createLogger('OAuth2Authorize') + +export const dynamic = 'force-dynamic' + +/** + * Browser-initiated entrypoint for linking a generic OAuth2 account. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const baseUrl = getBaseUrl() + + const session = await getSession() + if (!session?.user?.id) { + const loginUrl = new URL('/login', baseUrl) + loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search) + return NextResponse.redirect(loginUrl.toString()) + } + + const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) + if (!parsed.success) return parsed.response + const { providerId, callbackURL: requestedCallback } = parsed.data.query + + const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` + + try { + const linkResponse = await auth.api.oAuth2LinkAccount({ + body: { providerId, callbackURL }, + headers: request.headers, + asResponse: true, + }) + + const payload = (await linkResponse.json().catch(() => null)) as { url?: string } | null + if (!linkResponse.ok || !payload?.url) { + logger.error('oAuth2LinkAccount did not return an authorization URL', { + providerId, + status: linkResponse.status, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`) + } + + const response = NextResponse.redirect(payload.url) + // Forward the signed `state` cookie Better Auth set so it lands in the user's + // browser and is present when the provider redirects back to the callback. + const linkHeaders = linkResponse.headers as Headers & { + getSetCookie?: () => string[] + } + for (const cookie of linkHeaders.getSetCookie?.() ?? []) { + response.headers.append('set-cookie', cookie) + } + return response + } catch (error) { + logger.error('Failed to initiate OAuth2 authorization', { providerId, error }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`) + } +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 39980f0ab9..f89dcd772e 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -190,6 +190,18 @@ export const trelloCallbackContract = defineRouteContract({ response: { mode: 'text' }, }) +export const authorizeOAuth2QuerySchema = z.object({ + providerId: z.string().min(1, 'providerId is required'), + callbackURL: z.string().min(1).optional(), +}) + +export const authorizeOAuth2Contract = defineRouteContract({ + method: 'GET', + path: '/api/auth/oauth2/authorize', + query: authorizeOAuth2QuerySchema, + response: { mode: 'redirect' }, +}) + export type StoreTrelloTokenBody = ContractBody export type StoreTrelloTokenBodyInput = ContractBodyInput export type StoreTrelloTokenResponse = ContractJsonResponse diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index f19162f6fb..40037c938c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -687,12 +687,6 @@ export const auth = betterAuth({ ...SSO_TRUSTED_PROVIDERS, ], }, - // Mothership/headless OAuth links are generated server-side via auth.api.oAuth2LinkAccount, - // so better-auth's signed `state` cookie is set on the server-to-server response and never - // reaches the user's browser. With the database state strategy, the callback would then fail - // with state_security_mismatch (`?error=state_mismatch`). The DB verification record + PKCE - // still bind the flow, so skip the additional browser-cookie check. - skipStateCookieCheck: true, }, socialProviders: { ...(!isGithubAuthDisabled && { diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 1179f47c43..05122f23ae 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -69,11 +69,13 @@ export async function executeOAuthRequestAccess( } /** - * Resolves a human-friendly provider name to a providerId and generates the - * actual OAuth authorization URL via Better Auth's server-side API. + * Resolves a human-friendly provider name to a providerId and returns a + * browser-initiated authorize URL the user opens to connect the service. * - * Steps: resolve provider → create credential draft → look up user session → - * call auth.api.oAuth2LinkAccount → return the real authorization URL. + * Steps: resolve provider → create credential draft → return the Sim + * `/api/auth/oauth2/authorize` URL. That endpoint (not this server-side handler) + * calls Better Auth, so the signed `state` cookie is planted in the user's + * browser and the OAuth callback's state check passes. */ async function generateOAuthLink( userId: string, @@ -167,18 +169,15 @@ async function generateOAuthLink( }, }) - const { auth } = await import('@/lib/auth/auth') - const { headers: getHeaders } = await import('next/headers') - const reqHeaders = await getHeaders() + // Hand back a browser-initiated authorize URL rather than calling + // oAuth2LinkAccount here. Generating the link server-side would set Better + // Auth's signed `state` cookie on this server-to-server response instead of the + // user's browser, so the OAuth callback would fail with `state_mismatch`. The + // authorize endpoint runs the link inside the user's browser, planting the + // cookie correctly while keeping the callback's state check enabled. + const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) + authorizeUrl.searchParams.set('providerId', providerId) + authorizeUrl.searchParams.set('callbackURL', callbackURL) - const data = (await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, - headers: reqHeaders, - })) as { url?: string; redirect?: boolean } - - if (!data?.url) { - throw new Error('oAuth2LinkAccount did not return an authorization URL') - } - - return { url: data.url, providerId, serviceName } + return { url: authorizeUrl.toString(), providerId, serviceName } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6f63a52443..83cf378eb3 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 761, - zodRoutes: 761, + totalRoutes: 762, + zodRoutes: 762, nonZodRoutes: 0, } as const From 10f108570ba80ced9639c3f796977b0e4df487a3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 2 Jun 2026 12:04:37 -0700 Subject: [PATCH 3/3] fix draft timing issue --- .../app/api/auth/oauth2/authorize/route.ts | 84 ++++++++++++++++++- .../lib/api/contracts/oauth-connections.ts | 2 + apps/sim/lib/copilot/tools/handlers/oauth.ts | 60 +++---------- 3 files changed, 95 insertions(+), 51 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index c7238a5311..65bb6a773b 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,15 +1,81 @@ +import { db } from '@sim/db' +import { pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getAllOAuthServices } from '@/lib/oauth/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') export const dynamic = 'force-dynamic' +const DRAFT_TTL_MS = 15 * 60 * 1000 + +/** + * Creates the pending credential draft at click time so its TTL starts when the + * user actually initiates the connect. Better Auth's `account.create.after` hook + * consumes this draft to materialize the real credential after the OAuth + * callback; starting the clock here guarantees the draft outlives the (≤5 min) + * OAuth round-trip rather than expiring mid-flow and silently producing no + * credential. + */ +async function createConnectDraft(params: { + userId: string + workspaceId: string + providerId: string +}): Promise { + const { userId, workspaceId, providerId } = params + + const service = getAllOAuthServices().find((s) => s.providerId === providerId) + const serviceName = service?.name ?? providerId + + let displayName = serviceName + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (row?.name) { + displayName = `${row.name}'s ${serviceName}` + } + } catch { + // Fall back to service name only + } + + const now = new Date() + const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + await db + .delete(pendingCredentialDraft) + .where( + and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) + ) + await db + .insert(pendingCredentialDraft) + .values({ + id: generateId(), + userId, + workspaceId, + providerId, + displayName, + expiresAt, + createdAt: now, + }) + .onConflictDoUpdate({ + target: [ + pendingCredentialDraft.userId, + pendingCredentialDraft.providerId, + pendingCredentialDraft.workspaceId, + ], + set: { displayName, expiresAt, createdAt: now }, + }) + + logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) +} + /** * Browser-initiated entrypoint for linking a generic OAuth2 account. */ @@ -22,16 +88,32 @@ export const GET = withRouteHandler(async (request: NextRequest) => { loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search) return NextResponse.redirect(loginUrl.toString()) } + const userId = session.user.id const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { providerId, callbackURL: requestedCallback } = parsed.data.query + const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback : `${baseUrl}/workspace` try { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.canWrite) { + logger.warn('Workspace write access denied for OAuth2 authorize', { + userId, + workspaceId, + providerId, + }) + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + } + + // Create the draft before initiating the link so it is guaranteed to exist + // (and freshly clocked) when the OAuth callback's `account.create.after` + // hook runs. If this throws, we never start the OAuth flow. + await createConnectDraft({ userId, workspaceId, providerId }) + const linkResponse = await auth.api.oAuth2LinkAccount({ body: { providerId, callbackURL }, headers: request.headers, diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index f89dcd772e..f451f555c1 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import type { ContractBody, ContractBodyInput, @@ -192,6 +193,7 @@ export const trelloCallbackContract = defineRouteContract({ export const authorizeOAuth2QuerySchema = z.object({ providerId: z.string().min(1, 'providerId is required'), + workspaceId: workspaceIdSchema, callbackURL: z.string().min(1).optional(), }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 05122f23ae..15ae0d0455 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,8 +1,4 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft, user } from '@sim/db/schema' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -20,7 +16,6 @@ export async function executeOAuthGetAuthLink( } await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') const result = await generateOAuthLink( - context.userId, context.workspaceId, context.workflowId, context.chatId, @@ -72,13 +67,13 @@ export async function executeOAuthRequestAccess( * Resolves a human-friendly provider name to a providerId and returns a * browser-initiated authorize URL the user opens to connect the service. * - * Steps: resolve provider → create credential draft → return the Sim - * `/api/auth/oauth2/authorize` URL. That endpoint (not this server-side handler) - * calls Better Auth, so the signed `state` cookie is planted in the user's - * browser and the OAuth callback's state check passes. + * Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL. + * That endpoint (not this server-side handler) creates the credential draft and + * calls Better Auth, so the draft's TTL starts at click and the signed `state` + * cookie is planted in the user's browser and the OAuth callback's state check + * passes. */ async function generateOAuthLink( - userId: string, workspaceId: string | undefined, workflowId: string | undefined, chatId: string | undefined, @@ -129,54 +124,19 @@ async function generateOAuthLink( } } - let displayName = serviceName - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${serviceName}` - } - } catch { - // Fall back to service name only - } - - const now = new Date() - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - expiresAt: new Date(now.getTime() + 15 * 60 * 1000), - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { - displayName, - expiresAt: new Date(now.getTime() + 15 * 60 * 1000), - createdAt: now, - }, - }) - // Hand back a browser-initiated authorize URL rather than calling // oAuth2LinkAccount here. Generating the link server-side would set Better // Auth's signed `state` cookie on this server-to-server response instead of the // user's browser, so the OAuth callback would fail with `state_mismatch`. The // authorize endpoint runs the link inside the user's browser, planting the // cookie correctly while keeping the callback's state check enabled. + // + // The pending credential draft is created by that authorize endpoint at click + // time (not here), so the draft's TTL starts when the user actually initiates + // the connect and reliably outlives the OAuth round-trip. const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) authorizeUrl.searchParams.set('providerId', providerId) + authorizeUrl.searchParams.set('workspaceId', workspaceId) authorizeUrl.searchParams.set('callbackURL', callbackURL) return { url: authorizeUrl.toString(), providerId, serviceName }