Skip to content

Commit 40a77c2

Browse files
committed
feat(auth): add OAuth 2.1 provider for MCP connector support
1 parent 55920e9 commit 40a77c2

File tree

11 files changed

+12394
-20
lines changed

11 files changed

+12394
-20
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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+
}
91+
} catch {
92+
setError('Something went wrong. Please try again.')
93+
setSubmitting(false)
94+
}
95+
},
96+
[consentCode]
97+
)
98+
99+
const handleSwitchAccount = useCallback(async () => {
100+
const currentUrl = window.location.href
101+
await signOut({
102+
fetchOptions: {
103+
onSuccess: () => {
104+
window.location.href = `/login?callbackUrl=${encodeURIComponent(currentUrl)}`
105+
},
106+
},
107+
})
108+
}, [])
109+
110+
if (loading) {
111+
return (
112+
<div className='flex flex-col items-center justify-center'>
113+
<div className='space-y-1 text-center'>
114+
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
115+
Authorize Application
116+
</h1>
117+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
118+
Loading application details...
119+
</p>
120+
</div>
121+
</div>
122+
)
123+
}
124+
125+
if (error) {
126+
return (
127+
<div className='flex flex-col items-center justify-center'>
128+
<div className='space-y-1 text-center'>
129+
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
130+
Authorization Error
131+
</h1>
132+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
133+
{error}
134+
</p>
135+
</div>
136+
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
137+
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
138+
</div>
139+
</div>
140+
)
141+
}
142+
143+
const clientName = clientInfo?.name ?? clientId
144+
145+
return (
146+
<div className='flex flex-col items-center justify-center'>
147+
<div className='mb-6 flex items-center gap-4'>
148+
{clientInfo?.icon ? (
149+
<Image
150+
src={clientInfo.icon}
151+
alt={clientName ?? 'Application'}
152+
width={48}
153+
height={48}
154+
className='rounded-[10px]'
155+
unoptimized
156+
/>
157+
) : (
158+
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
159+
{(clientName ?? '?').charAt(0).toUpperCase()}
160+
</div>
161+
)}
162+
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
163+
<Image
164+
src='/new/logo/colorized-bg.svg'
165+
alt='Sim'
166+
width={48}
167+
height={48}
168+
className='rounded-[10px]'
169+
/>
170+
</div>
171+
172+
<div className='space-y-1 text-center'>
173+
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
174+
Authorize Application
175+
</h1>
176+
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
177+
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
178+
your account
179+
</p>
180+
</div>
181+
182+
{session?.user && (
183+
<div
184+
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
185+
>
186+
{session.user.image ? (
187+
<Image
188+
src={session.user.image}
189+
alt={session.user.name ?? 'User'}
190+
width={32}
191+
height={32}
192+
className='rounded-full'
193+
unoptimized
194+
/>
195+
) : (
196+
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
197+
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
198+
</div>
199+
)}
200+
<div className='min-w-0'>
201+
{session.user.name && (
202+
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
203+
)}
204+
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
205+
</div>
206+
<button
207+
type='button'
208+
onClick={handleSwitchAccount}
209+
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
210+
>
211+
Switch
212+
</button>
213+
</div>
214+
)}
215+
216+
{scopes.length > 0 && (
217+
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
218+
<div className='rounded-lg border p-4'>
219+
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
220+
<ul className='space-y-2'>
221+
{scopes.map((s) => (
222+
<li
223+
key={s}
224+
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
225+
>
226+
<span className='mt-0.5 text-green-500'>&#10003;</span>
227+
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
228+
</li>
229+
))}
230+
</ul>
231+
</div>
232+
</div>
233+
)}
234+
235+
<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
236+
<Button
237+
variant='outline'
238+
size='md'
239+
className='px-6 py-2'
240+
disabled={submitting}
241+
onClick={() => handleConsent(false)}
242+
>
243+
Deny
244+
</Button>
245+
<BrandedButton
246+
fullWidth
247+
showArrow={false}
248+
loading={submitting}
249+
loadingText='Authorizing'
250+
onClick={() => handleConsent(true)}
251+
>
252+
Allow
253+
</BrandedButton>
254+
</div>
255+
</div>
256+
)
257+
}

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

apps/sim/app/api/mcp/copilot/route.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { userStats } from '@sim/db/schema'
1616
import { createLogger } from '@sim/logger'
1717
import { eq, sql } from 'drizzle-orm'
1818
import { type NextRequest, NextResponse } from 'next/server'
19+
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
1920
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
2021
import {
2122
ORCHESTRATION_TIMEOUT_MS,
@@ -402,27 +403,51 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
402403
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
403404
const headers = (extra.requestInfo?.headers || {}) as HeaderMap
404405
const apiKeyHeader = readHeader(headers, 'x-api-key')
406+
const authorizationHeader = readHeader(headers, 'authorization')
405407

406-
if (!apiKeyHeader) {
407-
return {
408-
content: [
409-
{
410-
type: 'text' as const,
411-
text: 'AUTHENTICATION ERROR: No Copilot API key provided. The user must set their Copilot API key in the x-api-key header. They can generate one in the Sim app under Settings → Copilot. Do NOT retry — this will fail until the key is configured.',
412-
},
413-
],
414-
isError: true,
408+
let authResult: CopilotKeyAuthResult = { success: false }
409+
410+
if (authorizationHeader?.startsWith('Bearer ')) {
411+
const token = authorizationHeader.slice(7)
412+
const oauthResult = await validateOAuthAccessToken(token)
413+
if (oauthResult.success && oauthResult.userId) {
414+
if (!oauthResult.scopes?.includes('mcp:tools')) {
415+
return {
416+
content: [
417+
{
418+
type: 'text' as const,
419+
text: 'AUTHENTICATION ERROR: OAuth token is missing the required "mcp:tools" scope. Re-authorize with the correct scopes.',
420+
},
421+
],
422+
isError: true,
423+
}
424+
}
425+
authResult = { success: true, userId: oauthResult.userId }
426+
} else {
427+
return {
428+
content: [
429+
{
430+
type: 'text' as const,
431+
text: `AUTHENTICATION ERROR: ${oauthResult.error ?? 'Invalid OAuth access token'} Do NOT retry — re-authorize via OAuth.`,
432+
},
433+
],
434+
isError: true,
435+
}
415436
}
437+
} else if (apiKeyHeader) {
438+
authResult = await authenticateCopilotApiKey(apiKeyHeader)
416439
}
417440

418-
const authResult = await authenticateCopilotApiKey(apiKeyHeader)
419441
if (!authResult.success || !authResult.userId) {
420-
logger.warn('MCP copilot key auth failed', { method: request.method })
442+
const errorMsg = apiKeyHeader
443+
? `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`
444+
: 'AUTHENTICATION ERROR: No authentication provided. Provide a Bearer token (OAuth 2.1) or an x-api-key header. Generate a Copilot API key in Settings → Copilot.'
445+
logger.warn('MCP copilot auth failed', { method: request.method })
421446
return {
422447
content: [
423448
{
424449
type: 'text' as const,
425-
text: `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`,
450+
text: errorMsg,
426451
},
427452
],
428453
isError: true,
@@ -512,6 +537,19 @@ export async function GET() {
512537
}
513538

514539
export async function POST(request: NextRequest) {
540+
const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key')
541+
542+
if (!hasAuth) {
543+
const resourceMetadataUrl = `${request.nextUrl.origin}/.well-known/oauth-protected-resource/api/mcp/copilot`
544+
return new NextResponse(JSON.stringify({ error: 'unauthorized' }), {
545+
status: 401,
546+
headers: {
547+
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}"`,
548+
'Content-Type': 'application/json',
549+
},
550+
})
551+
}
552+
515553
try {
516554
let parsedBody: unknown
517555

@@ -532,6 +570,19 @@ export async function POST(request: NextRequest) {
532570
}
533571
}
534572

573+
export async function OPTIONS() {
574+
return new NextResponse(null, {
575+
status: 204,
576+
headers: {
577+
'Access-Control-Allow-Origin': '*',
578+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
579+
'Access-Control-Allow-Headers':
580+
'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
581+
'Access-Control-Max-Age': '86400',
582+
},
583+
})
584+
}
585+
535586
export async function DELETE(request: NextRequest) {
536587
void request
537588
return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 })

0 commit comments

Comments
 (0)