Skip to content

Commit 3fa4bb4

Browse files
authored
feat(auth): add OAuth 2.1 provider for MCP connector support (#3274)
* feat(auth): add OAuth 2.1 provider for MCP connector support * fix(auth): rename redirect_u_r_ls column to redirect_urls * chore(db): regenerate oauth migration with correct column naming * fix(auth): reorder CORS headers and handle missing redirectURI * fix(auth): redirect to login without stale callbackUrl on account switch * chore: run lint * fix(auth): override credentials header on OAuth CORS entries * fix(auth): preserve OAuth flow when switching accounts on consent page * fix(auth): add session and user-id checks to authorize-params endpoint * fix(auth): add expiry check, credentials, MCP CORS, and scope in WWW-Authenticate * feat(mcp): add tool annotations for Connectors Directory compliance
1 parent 1b8d666 commit 3fa4bb4

File tree

13 files changed

+12530
-20
lines changed

13 files changed

+12530
-20
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useState } from 'react'
4+
import { ArrowLeftRight } from 'lucide-react'
5+
import Image from 'next/image'
6+
import { useRouter, useSearchParams } from 'next/navigation'
7+
import { Button } from '@/components/emcn'
8+
import { signOut, useSession } from '@/lib/auth/auth-client'
9+
import { inter } from '@/app/_styles/fonts/inter/inter'
10+
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
11+
import { BrandedButton } from '@/app/(auth)/components/branded-button'
12+
13+
const SCOPE_DESCRIPTIONS: Record<string, string> = {
14+
openid: 'Verify your identity',
15+
profile: 'Access your basic profile information',
16+
email: 'View your email address',
17+
offline_access: 'Maintain access when you are not actively using the app',
18+
'mcp:tools': 'Use Sim workflows and tools on your behalf',
19+
} as const
20+
21+
interface ClientInfo {
22+
clientId: string
23+
name: string
24+
icon: string
25+
}
26+
27+
export default function OAuthConsentPage() {
28+
const router = useRouter()
29+
const searchParams = useSearchParams()
30+
const { data: session } = useSession()
31+
const consentCode = searchParams.get('consent_code')
32+
const clientId = searchParams.get('client_id')
33+
const scope = searchParams.get('scope')
34+
35+
const [clientInfo, setClientInfo] = useState<ClientInfo | null>(null)
36+
const [loading, setLoading] = useState(true)
37+
const [submitting, setSubmitting] = useState(false)
38+
const [error, setError] = useState<string | null>(null)
39+
40+
const scopes = scope?.split(' ').filter(Boolean) ?? []
41+
42+
useEffect(() => {
43+
if (!clientId) {
44+
setLoading(false)
45+
setError('The authorization request is missing a required client identifier.')
46+
return
47+
}
48+
49+
fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' })
50+
.then(async (res) => {
51+
if (!res.ok) return
52+
const data = await res.json()
53+
setClientInfo(data)
54+
})
55+
.catch(() => {})
56+
.finally(() => {
57+
setLoading(false)
58+
})
59+
}, [clientId])
60+
61+
const handleConsent = useCallback(
62+
async (accept: boolean) => {
63+
if (!consentCode) {
64+
setError('The authorization request is missing a required consent code.')
65+
return
66+
}
67+
68+
setSubmitting(true)
69+
try {
70+
const res = await fetch('/api/auth/oauth2/consent', {
71+
method: 'POST',
72+
headers: { 'Content-Type': 'application/json' },
73+
credentials: 'include',
74+
body: JSON.stringify({ accept, consent_code: consentCode }),
75+
})
76+
77+
if (!res.ok) {
78+
const body = await res.json().catch(() => null)
79+
setError(
80+
(body as Record<string, string> | null)?.message ??
81+
'The consent request could not be processed. Please try again.'
82+
)
83+
setSubmitting(false)
84+
return
85+
}
86+
87+
const data = (await res.json()) as { redirectURI?: string }
88+
if (data.redirectURI) {
89+
window.location.href = data.redirectURI
90+
} else {
91+
setError('The server did not return a redirect. Please try again.')
92+
setSubmitting(false)
93+
}
94+
} catch {
95+
setError('Something went wrong. Please try again.')
96+
setSubmitting(false)
97+
}
98+
},
99+
[consentCode]
100+
)
101+
102+
const handleSwitchAccount = useCallback(async () => {
103+
if (!consentCode) return
104+
105+
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
106+
credentials: 'include',
107+
})
108+
if (!res.ok) {
109+
setError('Unable to switch accounts. Please re-initiate the connection.')
110+
return
111+
}
112+
113+
const params = (await res.json()) as Record<string, string | null>
114+
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
115+
for (const [key, value] of Object.entries(params)) {
116+
if (value) authorizeUrl.searchParams.set(key, value)
117+
}
118+
119+
await signOut({
120+
fetchOptions: {
121+
onSuccess: () => {
122+
window.location.href = authorizeUrl.toString()
123+
},
124+
},
125+
})
126+
}, [consentCode])
127+
128+
if (loading) {
129+
return (
130+
<div className='flex flex-col items-center justify-center'>
131+
<div className='space-y-1 text-center'>
132+
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
133+
Authorize Application
134+
</h1>
135+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
136+
Loading application details...
137+
</p>
138+
</div>
139+
</div>
140+
)
141+
}
142+
143+
if (error) {
144+
return (
145+
<div className='flex flex-col items-center justify-center'>
146+
<div className='space-y-1 text-center'>
147+
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
148+
Authorization Error
149+
</h1>
150+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
151+
{error}
152+
</p>
153+
</div>
154+
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
155+
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
156+
</div>
157+
</div>
158+
)
159+
}
160+
161+
const clientName = clientInfo?.name ?? clientId
162+
163+
return (
164+
<div className='flex flex-col items-center justify-center'>
165+
<div className='mb-6 flex items-center gap-4'>
166+
{clientInfo?.icon ? (
167+
<Image
168+
src={clientInfo.icon}
169+
alt={clientName ?? 'Application'}
170+
width={48}
171+
height={48}
172+
className='rounded-[10px]'
173+
unoptimized
174+
/>
175+
) : (
176+
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
177+
{(clientName ?? '?').charAt(0).toUpperCase()}
178+
</div>
179+
)}
180+
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
181+
<Image
182+
src='/new/logo/colorized-bg.svg'
183+
alt='Sim'
184+
width={48}
185+
height={48}
186+
className='rounded-[10px]'
187+
/>
188+
</div>
189+
190+
<div className='space-y-1 text-center'>
191+
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
192+
Authorize Application
193+
</h1>
194+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
195+
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
196+
your account
197+
</p>
198+
</div>
199+
200+
{session?.user && (
201+
<div
202+
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
203+
>
204+
{session.user.image ? (
205+
<Image
206+
src={session.user.image}
207+
alt={session.user.name ?? 'User'}
208+
width={32}
209+
height={32}
210+
className='rounded-full'
211+
unoptimized
212+
/>
213+
) : (
214+
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
215+
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
216+
</div>
217+
)}
218+
<div className='min-w-0'>
219+
{session.user.name && (
220+
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
221+
)}
222+
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
223+
</div>
224+
<button
225+
type='button'
226+
onClick={handleSwitchAccount}
227+
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
228+
>
229+
Switch
230+
</button>
231+
</div>
232+
)}
233+
234+
{scopes.length > 0 && (
235+
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
236+
<div className='rounded-lg border p-4'>
237+
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
238+
<ul className='space-y-2'>
239+
{scopes.map((s) => (
240+
<li
241+
key={s}
242+
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
243+
>
244+
<span className='mt-0.5 text-green-500'>&#10003;</span>
245+
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
246+
</li>
247+
))}
248+
</ul>
249+
</div>
250+
</div>
251+
)}
252+
253+
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
254+
<Button
255+
variant='outline'
256+
size='md'
257+
className='px-6 py-2'
258+
disabled={submitting}
259+
onClick={() => handleConsent(false)}
260+
>
261+
Deny
262+
</Button>
263+
<BrandedButton
264+
fullWidth
265+
showArrow={false}
266+
loading={submitting}
267+
loadingText='Authorizing'
268+
onClick={() => handleConsent(true)}
269+
>
270+
Allow
271+
</BrandedButton>
272+
</div>
273+
</div>
274+
)
275+
}

apps/sim/app/_shell/providers/theme-provider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
2323
pathname.startsWith('/chat') ||
2424
pathname.startsWith('/studio') ||
2525
pathname.startsWith('/resume') ||
26-
pathname.startsWith('/form')
26+
pathname.startsWith('/form') ||
27+
pathname.startsWith('/oauth')
2728

2829
return (
2930
<NextThemesProvider
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { db } from '@sim/db'
2+
import { verification } from '@sim/db/schema'
3+
import { and, eq, gt } from 'drizzle-orm'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { getSession } from '@/lib/auth'
7+
8+
/**
9+
* Returns the original OAuth authorize parameters stored in the verification record
10+
* for a given consent code. Used by the consent page to reconstruct the authorize URL
11+
* when switching accounts.
12+
*/
13+
export async function GET(request: NextRequest) {
14+
const session = await getSession()
15+
if (!session?.user) {
16+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
17+
}
18+
19+
const consentCode = request.nextUrl.searchParams.get('consent_code')
20+
if (!consentCode) {
21+
return NextResponse.json({ error: 'consent_code is required' }, { status: 400 })
22+
}
23+
24+
const [record] = await db
25+
.select({ value: verification.value })
26+
.from(verification)
27+
.where(and(eq(verification.identifier, consentCode), gt(verification.expiresAt, new Date())))
28+
.limit(1)
29+
30+
if (!record) {
31+
return NextResponse.json({ error: 'Invalid or expired consent code' }, { status: 404 })
32+
}
33+
34+
const data = JSON.parse(record.value) as {
35+
clientId: string
36+
redirectURI: string
37+
scope: string[]
38+
userId: string
39+
codeChallenge: string
40+
codeChallengeMethod: string
41+
state: string | null
42+
nonce: string | null
43+
}
44+
45+
if (data.userId !== session.user.id) {
46+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
47+
}
48+
49+
return NextResponse.json({
50+
client_id: data.clientId,
51+
redirect_uri: data.redirectURI,
52+
scope: data.scope.join(' '),
53+
code_challenge: data.codeChallenge,
54+
code_challenge_method: data.codeChallengeMethod,
55+
state: data.state,
56+
nonce: data.nonce,
57+
response_type: 'code',
58+
})
59+
}

0 commit comments

Comments
 (0)