Skip to content

Commit 733656c

Browse files
committed
browser initated solution
1 parent 76381ff commit 733656c

5 files changed

Lines changed: 94 additions & 25 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { auth, getSession } from '@/lib/auth/auth'
6+
import { getBaseUrl } from '@/lib/core/utils/urls'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
9+
const logger = createLogger('OAuth2Authorize')
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
/**
14+
* Browser-initiated entrypoint for linking a generic OAuth2 account.
15+
*/
16+
export const GET = withRouteHandler(async (request: NextRequest) => {
17+
const baseUrl = getBaseUrl()
18+
19+
const session = await getSession()
20+
if (!session?.user?.id) {
21+
const loginUrl = new URL('/login', baseUrl)
22+
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname + request.nextUrl.search)
23+
return NextResponse.redirect(loginUrl.toString())
24+
}
25+
26+
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
27+
if (!parsed.success) return parsed.response
28+
const { providerId, callbackURL: requestedCallback } = parsed.data.query
29+
30+
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
31+
? requestedCallback
32+
: `${baseUrl}/workspace`
33+
34+
try {
35+
const linkResponse = await auth.api.oAuth2LinkAccount({
36+
body: { providerId, callbackURL },
37+
headers: request.headers,
38+
asResponse: true,
39+
})
40+
41+
const payload = (await linkResponse.json().catch(() => null)) as { url?: string } | null
42+
if (!linkResponse.ok || !payload?.url) {
43+
logger.error('oAuth2LinkAccount did not return an authorization URL', {
44+
providerId,
45+
status: linkResponse.status,
46+
})
47+
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
48+
}
49+
50+
const response = NextResponse.redirect(payload.url)
51+
// Forward the signed `state` cookie Better Auth set so it lands in the user's
52+
// browser and is present when the provider redirects back to the callback.
53+
const linkHeaders = linkResponse.headers as Headers & {
54+
getSetCookie?: () => string[]
55+
}
56+
for (const cookie of linkHeaders.getSetCookie?.() ?? []) {
57+
response.headers.append('set-cookie', cookie)
58+
}
59+
return response
60+
} catch (error) {
61+
logger.error('Failed to initiate OAuth2 authorization', { providerId, error })
62+
return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`)
63+
}
64+
})

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,18 @@ export const trelloCallbackContract = defineRouteContract({
190190
response: { mode: 'text' },
191191
})
192192

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

apps/sim/lib/auth/auth.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -687,12 +687,6 @@ export const auth = betterAuth({
687687
...SSO_TRUSTED_PROVIDERS,
688688
],
689689
},
690-
// Mothership/headless OAuth links are generated server-side via auth.api.oAuth2LinkAccount,
691-
// so better-auth's signed `state` cookie is set on the server-to-server response and never
692-
// reaches the user's browser. With the database state strategy, the callback would then fail
693-
// with state_security_mismatch (`?error=state_mismatch`). The DB verification record + PKCE
694-
// still bind the flow, so skip the additional browser-cookie check.
695-
skipStateCookieCheck: true,
696690
},
697691
socialProviders: {
698692
...(!isGithubAuthDisabled && {

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

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,13 @@ export async function executeOAuthRequestAccess(
6969
}
7070

7171
/**
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.
72+
* Resolves a human-friendly provider name to a providerId and returns a
73+
* browser-initiated authorize URL the user opens to connect the service.
7474
*
75-
* Steps: resolve provider → create credential draft → look up user session →
76-
* call auth.api.oAuth2LinkAccount → return the real authorization URL.
75+
* Steps: resolve provider → create credential draft → return the Sim
76+
* `/api/auth/oauth2/authorize` URL. That endpoint (not this server-side handler)
77+
* calls Better Auth, so the signed `state` cookie is planted in the user's
78+
* browser and the OAuth callback's state check passes.
7779
*/
7880
async function generateOAuthLink(
7981
userId: string,
@@ -167,18 +169,15 @@ async function generateOAuthLink(
167169
},
168170
})
169171

170-
const { auth } = await import('@/lib/auth/auth')
171-
const { headers: getHeaders } = await import('next/headers')
172-
const reqHeaders = await getHeaders()
172+
// Hand back a browser-initiated authorize URL rather than calling
173+
// oAuth2LinkAccount here. Generating the link server-side would set Better
174+
// Auth's signed `state` cookie on this server-to-server response instead of the
175+
// user's browser, so the OAuth callback would fail with `state_mismatch`. The
176+
// authorize endpoint runs the link inside the user's browser, planting the
177+
// cookie correctly while keeping the callback's state check enabled.
178+
const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`)
179+
authorizeUrl.searchParams.set('providerId', providerId)
180+
authorizeUrl.searchParams.set('callbackURL', callbackURL)
173181

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-
}
182-
183-
return { url: data.url, providerId, serviceName }
182+
return { url: authorizeUrl.toString(), providerId, serviceName }
184183
}

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)