Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
64 changes: 64 additions & 0 deletions apps/sim/app/api/auth/oauth2/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
icecrasher321 marked this conversation as resolved.
return response
} catch (error) {
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
}
})
12 changes: 12 additions & 0 deletions apps/sim/lib/api/contracts/oauth-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof storeTrelloTokenContract>
export type StoreTrelloTokenBodyInput = ContractBodyInput<typeof storeTrelloTokenContract>
export type StoreTrelloTokenResponse = ContractJsonResponse<typeof storeTrelloTokenContract>
33 changes: 16 additions & 17 deletions apps/sim/lib/copilot/tools/handlers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
}
4 changes: 2 additions & 2 deletions scripts/check-api-validation-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading