Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
259 changes: 259 additions & 0 deletions apps/sim/app/(auth)/oauth/consent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import { ArrowLeftRight } from 'lucide-react'
import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/emcn'
import { signOut, useSession } from '@/lib/auth/auth-client'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'

const SCOPE_DESCRIPTIONS: Record<string, string> = {
openid: 'Verify your identity',
profile: 'Access your basic profile information',
email: 'View your email address',
offline_access: 'Maintain access when you are not actively using the app',
'mcp:tools': 'Use Sim workflows and tools on your behalf',
} as const

interface ClientInfo {
clientId: string
name: string
icon: string
}

export default function OAuthConsentPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
const consentCode = searchParams.get('consent_code')
const clientId = searchParams.get('client_id')
const scope = searchParams.get('scope')

const [clientInfo, setClientInfo] = useState<ClientInfo | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)

const scopes = scope?.split(' ').filter(Boolean) ?? []

useEffect(() => {
if (!clientId) {
setLoading(false)
setError('The authorization request is missing a required client identifier.')
return
}

fetch(`/api/auth/oauth2/client/${clientId}`, { credentials: 'include' })
.then(async (res) => {
if (!res.ok) return
const data = await res.json()
setClientInfo(data)
})
.catch(() => {})
.finally(() => {
setLoading(false)
})
}, [clientId])

const handleConsent = useCallback(
async (accept: boolean) => {
if (!consentCode) {
setError('The authorization request is missing a required consent code.')
return
}

setSubmitting(true)
try {
const res = await fetch('/api/auth/oauth2/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ accept, consent_code: consentCode }),
})

if (!res.ok) {
const body = await res.json().catch(() => null)
setError(
(body as Record<string, string> | null)?.message ??
'The consent request could not be processed. Please try again.'
)
setSubmitting(false)
return
}

const data = (await res.json()) as { redirectURI?: string }
if (data.redirectURI) {
window.location.href = data.redirectURI
} else {
setError('The server did not return a redirect. Please try again.')
setSubmitting(false)
}
Comment thread
waleedlatif1 marked this conversation as resolved.
} catch {
setError('Something went wrong. Please try again.')
setSubmitting(false)
}
},
[consentCode]
)

const handleSwitchAccount = useCallback(async () => {
await signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = '/login'
},
},
})
}, [])
Comment thread
waleedlatif1 marked this conversation as resolved.
Outdated

if (loading) {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
Loading application details...
</p>
</div>
</div>
)
}

if (error) {
return (
<div className='flex flex-col items-center justify-center'>
<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorization Error
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
{error}
</p>
</div>
<div className={`${inter.className} mt-8 w-full max-w-[410px] space-y-3`}>
<BrandedButton onClick={() => router.push('/')}>Return to Home</BrandedButton>
</div>
</div>
)
}

const clientName = clientInfo?.name ?? clientId

return (
<div className='flex flex-col items-center justify-center'>
<div className='mb-6 flex items-center gap-4'>
{clientInfo?.icon ? (
<Image
src={clientInfo.icon}
alt={clientName ?? 'Application'}
width={48}
height={48}
className='rounded-[10px]'
unoptimized
/>
) : (
<div className='flex h-12 w-12 items-center justify-center rounded-[10px] bg-muted font-medium text-[18px] text-muted-foreground'>
{(clientName ?? '?').charAt(0).toUpperCase()}
</div>
)}
<ArrowLeftRight className='h-5 w-5 text-muted-foreground' />
<Image
src='/new/logo/colorized-bg.svg'
alt='Sim'
width={48}
height={48}
className='rounded-[10px]'
/>
</div>

<div className='space-y-1 text-center'>
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
Authorize Application
</h1>
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
<span className='font-medium text-foreground'>{clientName}</span> is requesting access to
your account
</p>
</div>

{session?.user && (
<div
className={`${inter.className} mt-5 flex items-center gap-3 rounded-lg border px-4 py-3`}
>
{session.user.image ? (
<Image
src={session.user.image}
alt={session.user.name ?? 'User'}
width={32}
height={32}
className='rounded-full'
unoptimized
/>
) : (
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-[13px] text-muted-foreground'>
{(session.user.name ?? session.user.email ?? '?').charAt(0).toUpperCase()}
</div>
)}
<div className='min-w-0'>
{session.user.name && (
<p className='truncate font-medium text-[14px]'>{session.user.name}</p>
)}
<p className='truncate text-[13px] text-muted-foreground'>{session.user.email}</p>
</div>
<button
type='button'
onClick={handleSwitchAccount}
className='ml-auto text-[13px] text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline'
>
Switch
</button>
</div>
)}

{scopes.length > 0 && (
<div className={`${inter.className} mt-5 w-full max-w-[410px]`}>
<div className='rounded-lg border p-4'>
<p className='mb-3 font-medium text-[14px]'>This will allow the application to:</p>
<ul className='space-y-2'>
{scopes.map((s) => (
<li
key={s}
className='flex items-start gap-2 font-normal text-[13px] text-muted-foreground'
>
<span className='mt-0.5 text-green-500'>&#10003;</span>
<span>{SCOPE_DESCRIPTIONS[s] ?? s}</span>
</li>
))}
</ul>
</div>
</div>
)}

<div className={`${inter.className} mt-6 flex w-full max-w-[410px] gap-3`}>
<Button
variant='outline'
size='md'
className='px-6 py-2'
disabled={submitting}
onClick={() => handleConsent(false)}
>
Deny
</Button>
<BrandedButton
fullWidth
showArrow={false}
loading={submitting}
loadingText='Authorizing'
onClick={() => handleConsent(true)}
>
Allow
</BrandedButton>
</div>
</div>
)
}
3 changes: 2 additions & 1 deletion apps/sim/app/_shell/providers/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/chat') ||
pathname.startsWith('/studio') ||
pathname.startsWith('/resume') ||
pathname.startsWith('/form')
pathname.startsWith('/form') ||
pathname.startsWith('/oauth')

return (
<NextThemesProvider
Expand Down
75 changes: 63 additions & 12 deletions apps/sim/app/api/mcp/copilot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import {
ORCHESTRATION_TIMEOUT_MS,
Expand Down Expand Up @@ -402,27 +403,51 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const headers = (extra.requestInfo?.headers || {}) as HeaderMap
const apiKeyHeader = readHeader(headers, 'x-api-key')
const authorizationHeader = readHeader(headers, 'authorization')

if (!apiKeyHeader) {
return {
content: [
{
type: 'text' as const,
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.',
},
],
isError: true,
let authResult: CopilotKeyAuthResult = { success: false }

if (authorizationHeader?.startsWith('Bearer ')) {
const token = authorizationHeader.slice(7)
const oauthResult = await validateOAuthAccessToken(token)
if (oauthResult.success && oauthResult.userId) {
if (!oauthResult.scopes?.includes('mcp:tools')) {
return {
content: [
{
type: 'text' as const,
text: 'AUTHENTICATION ERROR: OAuth token is missing the required "mcp:tools" scope. Re-authorize with the correct scopes.',
},
],
isError: true,
}
}
authResult = { success: true, userId: oauthResult.userId }
} else {
return {
content: [
{
type: 'text' as const,
text: `AUTHENTICATION ERROR: ${oauthResult.error ?? 'Invalid OAuth access token'} Do NOT retry — re-authorize via OAuth.`,
},
],
isError: true,
}
}
} else if (apiKeyHeader) {
authResult = await authenticateCopilotApiKey(apiKeyHeader)
Comment thread
waleedlatif1 marked this conversation as resolved.
}

const authResult = await authenticateCopilotApiKey(apiKeyHeader)
if (!authResult.success || !authResult.userId) {
logger.warn('MCP copilot key auth failed', { method: request.method })
const errorMsg = apiKeyHeader
? `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`
: '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.'
logger.warn('MCP copilot auth failed', { method: request.method })
return {
content: [
{
type: 'text' as const,
text: `AUTHENTICATION ERROR: ${authResult.error} Do NOT retry — this will fail until the user fixes their Copilot API key.`,
text: errorMsg,
},
],
isError: true,
Expand Down Expand Up @@ -512,6 +537,19 @@ export async function GET() {
}

export async function POST(request: NextRequest) {
const hasAuth = request.headers.has('authorization') || request.headers.has('x-api-key')

if (!hasAuth) {
const resourceMetadataUrl = `${request.nextUrl.origin}/.well-known/oauth-protected-resource/api/mcp/copilot`
return new NextResponse(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: {
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}"`,
'Content-Type': 'application/json',
},
})
}

try {
let parsedBody: unknown

Expand All @@ -532,6 +570,19 @@ export async function POST(request: NextRequest) {
}
}

export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS, DELETE',
'Access-Control-Allow-Headers':
'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
'Access-Control-Max-Age': '86400',
},
})
}

export async function DELETE(request: NextRequest) {
void request
return NextResponse.json(createError(0, -32000, 'Method not allowed.'), { status: 405 })
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/blocks/blocks/jira_service_management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,8 @@ Return ONLY the description text - no explanations.`,
id: 'requestFieldValues',
title: 'Request Field Values',
type: 'long-input',
placeholder: 'JSON object of field values (e.g., {"summary": "Title", "customfield_10010": "value"})',
placeholder:
'JSON object of field values (e.g., {"summary": "Title", "customfield_10010": "value"})',
condition: { field: 'operation', value: 'create_request' },
},
{
Expand Down
Loading