mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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
This commit is contained in:
275
apps/sim/app/(auth)/oauth/consent/page.tsx
Normal file
275
apps/sim/app/(auth)/oauth/consent/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'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)
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[consentCode]
|
||||
)
|
||||
|
||||
const handleSwitchAccount = useCallback(async () => {
|
||||
if (!consentCode) return
|
||||
|
||||
const res = await fetch(`/api/auth/oauth2/authorize-params?consent_code=${consentCode}`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError('Unable to switch accounts. Please re-initiate the connection.')
|
||||
return
|
||||
}
|
||||
|
||||
const params = (await res.json()) as Record<string, string | null>
|
||||
const authorizeUrl = new URL('/api/auth/oauth2/authorize', window.location.origin)
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) authorizeUrl.searchParams.set(key, value)
|
||||
}
|
||||
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
window.location.href = authorizeUrl.toString()
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [consentCode])
|
||||
|
||||
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'>✓</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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
59
apps/sim/app/api/auth/oauth2/authorize-params/route.ts
Normal file
59
apps/sim/app/api/auth/oauth2/authorize-params/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { db } from '@sim/db'
|
||||
import { verification } from '@sim/db/schema'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* Returns the original OAuth authorize parameters stored in the verification record
|
||||
* for a given consent code. Used by the consent page to reconstruct the authorize URL
|
||||
* when switching accounts.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const consentCode = request.nextUrl.searchParams.get('consent_code')
|
||||
if (!consentCode) {
|
||||
return NextResponse.json({ error: 'consent_code is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [record] = await db
|
||||
.select({ value: verification.value })
|
||||
.from(verification)
|
||||
.where(and(eq(verification.identifier, consentCode), gt(verification.expiresAt, new Date())))
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
return NextResponse.json({ error: 'Invalid or expired consent code' }, { status: 404 })
|
||||
}
|
||||
|
||||
const data = JSON.parse(record.value) as {
|
||||
clientId: string
|
||||
redirectURI: string
|
||||
scope: string[]
|
||||
userId: string
|
||||
codeChallenge: string
|
||||
codeChallengeMethod: string
|
||||
state: string | null
|
||||
nonce: string | null
|
||||
}
|
||||
|
||||
if (data.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
client_id: data.clientId,
|
||||
redirect_uri: data.redirectURI,
|
||||
scope: data.scope.join(' '),
|
||||
code_challenge: data.codeChallenge,
|
||||
code_challenge_method: data.codeChallengeMethod,
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
response_type: 'code',
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
@@ -384,12 +385,14 @@ function buildMcpServer(abortSignal?: AbortSignal): Server {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.annotations && { annotations: tool.annotations }),
|
||||
}))
|
||||
|
||||
const subagentTools = SUBAGENT_TOOL_DEFS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
...(tool.annotations && { annotations: tool.annotations }),
|
||||
}))
|
||||
|
||||
const result: ListToolsResult = {
|
||||
@@ -402,27 +405,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)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -512,6 +539,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}", scope="mcp:tools"`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
let parsedBody: unknown
|
||||
|
||||
@@ -532,6 +572,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 })
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
customSession,
|
||||
emailOTP,
|
||||
genericOAuth,
|
||||
jwt,
|
||||
oidcProvider,
|
||||
oneTimeToken,
|
||||
organization,
|
||||
} from 'better-auth/plugins'
|
||||
@@ -80,6 +82,8 @@ export const auth = betterAuth({
|
||||
trustedOrigins: [
|
||||
getBaseUrl(),
|
||||
...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []),
|
||||
'https://claude.ai',
|
||||
'https://claude.com',
|
||||
].filter(Boolean),
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
@@ -542,6 +546,21 @@ export const auth = betterAuth({
|
||||
},
|
||||
plugins: [
|
||||
nextCookies(),
|
||||
jwt({
|
||||
jwks: {
|
||||
keyPairConfig: { alg: 'RS256' },
|
||||
},
|
||||
disableSettingJwtHeader: true,
|
||||
}),
|
||||
oidcProvider({
|
||||
loginPage: '/login',
|
||||
consentPage: '/oauth/consent',
|
||||
requirePKCE: true,
|
||||
allowPlainCodeChallengeMethod: false,
|
||||
allowDynamicClientRegistration: true,
|
||||
useJWTPlugin: true,
|
||||
scopes: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
|
||||
}),
|
||||
oneTimeToken({
|
||||
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
|
||||
}),
|
||||
|
||||
51
apps/sim/lib/auth/oauth-token.ts
Normal file
51
apps/sim/lib/auth/oauth-token.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from '@sim/db'
|
||||
import { oauthAccessToken } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, gt } from 'drizzle-orm'
|
||||
|
||||
const logger = createLogger('OAuthToken')
|
||||
|
||||
interface OAuthTokenValidationResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
scopes?: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an OAuth 2.1 access token by looking it up in the oauthAccessToken table.
|
||||
* Returns the associated userId and scopes if the token is valid and not expired.
|
||||
*/
|
||||
export async function validateOAuthAccessToken(token: string): Promise<OAuthTokenValidationResult> {
|
||||
try {
|
||||
const [record] = await db
|
||||
.select({
|
||||
userId: oauthAccessToken.userId,
|
||||
scopes: oauthAccessToken.scopes,
|
||||
accessTokenExpiresAt: oauthAccessToken.accessTokenExpiresAt,
|
||||
})
|
||||
.from(oauthAccessToken)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthAccessToken.accessToken, token),
|
||||
gt(oauthAccessToken.accessTokenExpiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!record) {
|
||||
return { success: false, error: 'Invalid or expired OAuth access token' }
|
||||
}
|
||||
|
||||
if (!record.userId) {
|
||||
return { success: false, error: 'OAuth token has no associated user' }
|
||||
}
|
||||
|
||||
const scopes = record.scopes.split(' ').filter(Boolean)
|
||||
|
||||
return { success: true, userId: record.userId, scopes }
|
||||
} catch (error) {
|
||||
logger.error('OAuth access token validation failed', { error })
|
||||
return { success: false, error: 'Token validation error' }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
export type ToolAnnotations = {
|
||||
readOnlyHint?: boolean
|
||||
destructiveHint?: boolean
|
||||
idempotentHint?: boolean
|
||||
openWorldHint?: boolean
|
||||
}
|
||||
|
||||
export type DirectToolDef = {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
|
||||
toolId: string
|
||||
annotations?: ToolAnnotations
|
||||
}
|
||||
|
||||
export type SubagentToolDef = {
|
||||
@@ -10,6 +18,7 @@ export type SubagentToolDef = {
|
||||
description: string
|
||||
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[] }
|
||||
agentId: string
|
||||
annotations?: ToolAnnotations
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +35,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'list_workflows',
|
||||
@@ -45,6 +55,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'list_folders',
|
||||
@@ -61,6 +72,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workspaceId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'get_workflow',
|
||||
@@ -77,6 +89,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'create_workflow',
|
||||
@@ -105,6 +118,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'create_folder',
|
||||
@@ -129,6 +143,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'rename_workflow',
|
||||
@@ -148,6 +163,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'name'],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: 'move_workflow',
|
||||
@@ -168,6 +184,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: 'move_folder',
|
||||
@@ -189,6 +206,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['folderId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_workflow',
|
||||
@@ -214,6 +232,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_workflow_until_block',
|
||||
@@ -243,6 +262,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'stopAfterBlockId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_from_block',
|
||||
@@ -276,6 +296,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'startBlockId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'run_block',
|
||||
@@ -309,6 +330,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId', 'blockId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'get_deployed_workflow_state',
|
||||
@@ -325,6 +347,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'generate_api_key',
|
||||
@@ -346,6 +369,7 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -397,6 +421,7 @@ WORKFLOW:
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_discovery',
|
||||
@@ -422,6 +447,7 @@ DO NOT USE (use direct tools instead):
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_plan',
|
||||
@@ -456,6 +482,7 @@ IMPORTANT: Pass the returned plan EXACTLY to sim_edit - do not modify or summari
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_edit',
|
||||
@@ -491,6 +518,7 @@ After sim_edit completes, you can test immediately with sim_test, or deploy with
|
||||
},
|
||||
required: ['workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_deploy',
|
||||
@@ -524,6 +552,7 @@ ALSO CAN:
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_test',
|
||||
@@ -547,6 +576,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request', 'workflowId'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_debug',
|
||||
@@ -562,6 +592,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['error', 'workflowId'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_auth',
|
||||
@@ -576,6 +607,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_knowledge',
|
||||
@@ -590,6 +622,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'sim_custom_tool',
|
||||
@@ -604,6 +637,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'sim_info',
|
||||
@@ -619,6 +653,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_workflow',
|
||||
@@ -634,6 +669,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: false },
|
||||
},
|
||||
{
|
||||
name: 'sim_research',
|
||||
@@ -648,6 +684,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_superagent',
|
||||
@@ -662,6 +699,7 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { destructiveHint: true, openWorldHint: true },
|
||||
},
|
||||
{
|
||||
name: 'sim_platform',
|
||||
@@ -676,5 +714,6 @@ Supports full and partial execution:
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -10,15 +10,17 @@ export function createMcpAuthorizationServerMetadataResponse(request: NextReques
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
issuer: resource,
|
||||
token_endpoint: `${origin}/api/auth/oauth/token`,
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
issuer: origin,
|
||||
authorization_endpoint: `${origin}/api/auth/oauth2/authorize`,
|
||||
token_endpoint: `${origin}/api/auth/oauth2/token`,
|
||||
registration_endpoint: `${origin}/api/auth/oauth2/register`,
|
||||
jwks_uri: `${origin}/api/auth/jwks`,
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
scopes_supported: ['mcp:tools'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'offline_access', 'mcp:tools'],
|
||||
resource,
|
||||
// Non-standard extension for API-key-only clients.
|
||||
x_sim_auth: {
|
||||
type: 'api_key',
|
||||
header: 'x-api-key',
|
||||
@@ -35,7 +37,7 @@ export function createMcpAuthorizationServerMetadataResponse(request: NextReques
|
||||
export function createMcpProtectedResourceMetadataResponse(request: NextRequest): NextResponse {
|
||||
const origin = getOrigin(request)
|
||||
const resource = `${origin}/api/mcp/copilot`
|
||||
const authorizationServerIssuer = `${origin}/api/mcp/copilot`
|
||||
const authorizationServerIssuer = origin
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -121,6 +121,14 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/.well-known/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// API routes CORS headers
|
||||
source: '/api/:path*',
|
||||
@@ -137,7 +145,52 @@ const nextConfig: NextConfig = {
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value:
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key',
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/oauth2/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, POST, OPTIONS' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization, Accept',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/jwks',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/auth/.well-known/:path*',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{ key: 'Access-Control-Allow-Methods', value: 'GET, OPTIONS' },
|
||||
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type, Accept' },
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/api/mcp/copilot',
|
||||
headers: [
|
||||
{ key: 'Access-Control-Allow-Credentials', value: 'false' },
|
||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
||||
{
|
||||
key: 'Access-Control-Allow-Methods',
|
||||
value: 'GET, POST, OPTIONS, DELETE',
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Headers',
|
||||
value: 'Content-Type, Authorization, X-API-Key, X-Requested-With, Accept',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
57
packages/db/migrations/0157_exotic_dormammu.sql
Normal file
57
packages/db/migrations/0157_exotic_dormammu.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
CREATE TABLE "jwks" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"public_key" text NOT NULL,
|
||||
"private_key" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_access_token" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"access_token" text NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"access_token_expires_at" timestamp NOT NULL,
|
||||
"refresh_token_expires_at" timestamp NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"scopes" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "oauth_access_token_access_token_unique" UNIQUE("access_token"),
|
||||
CONSTRAINT "oauth_access_token_refresh_token_unique" UNIQUE("refresh_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_application" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"icon" text,
|
||||
"metadata" text,
|
||||
"client_id" text NOT NULL,
|
||||
"client_secret" text,
|
||||
"redirect_urls" text NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"disabled" boolean DEFAULT false,
|
||||
"user_id" text,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
CONSTRAINT "oauth_application_client_id_unique" UNIQUE("client_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "oauth_consent" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"client_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"scopes" text NOT NULL,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"consent_given" boolean NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_application" ADD CONSTRAINT "oauth_application_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_application_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_application"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "oauth_access_token_access_token_idx" ON "oauth_access_token" USING btree ("access_token");--> statement-breakpoint
|
||||
CREATE INDEX "oauth_access_token_refresh_token_idx" ON "oauth_access_token" USING btree ("refresh_token");--> statement-breakpoint
|
||||
CREATE INDEX "oauth_application_client_id_idx" ON "oauth_application" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX "oauth_consent_user_client_idx" ON "oauth_consent" USING btree ("user_id","client_id");
|
||||
11824
packages/db/migrations/meta/0157_snapshot.json
Normal file
11824
packages/db/migrations/meta/0157_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1093,6 +1093,13 @@
|
||||
"when": 1771528429740,
|
||||
"tag": "0156_easy_odin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 157,
|
||||
"version": "7",
|
||||
"when": 1771621587420,
|
||||
"tag": "0157_exotic_dormammu",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2334,3 +2334,73 @@ export const userTableRows = pgTable(
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const oauthApplication = pgTable(
|
||||
'oauth_application',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
icon: text('icon'),
|
||||
metadata: text('metadata'),
|
||||
clientId: text('client_id').notNull().unique(),
|
||||
clientSecret: text('client_secret'),
|
||||
redirectURLs: text('redirect_urls').notNull(),
|
||||
type: text('type').notNull(),
|
||||
disabled: boolean('disabled').default(false),
|
||||
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
clientIdIdx: index('oauth_application_client_id_idx').on(table.clientId),
|
||||
})
|
||||
)
|
||||
|
||||
export const oauthAccessToken = pgTable(
|
||||
'oauth_access_token',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
accessToken: text('access_token').notNull().unique(),
|
||||
refreshToken: text('refresh_token').notNull().unique(),
|
||||
accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(),
|
||||
refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => oauthApplication.clientId, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
|
||||
scopes: text('scopes').notNull(),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
accessTokenIdx: index('oauth_access_token_access_token_idx').on(table.accessToken),
|
||||
refreshTokenIdx: index('oauth_access_token_refresh_token_idx').on(table.refreshToken),
|
||||
})
|
||||
)
|
||||
|
||||
export const oauthConsent = pgTable(
|
||||
'oauth_consent',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
clientId: text('client_id')
|
||||
.notNull()
|
||||
.references(() => oauthApplication.clientId, { onDelete: 'cascade' }),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
scopes: text('scopes').notNull(),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
consentGiven: boolean('consent_given').notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userClientIdx: index('oauth_consent_user_client_idx').on(table.userId, table.clientId),
|
||||
})
|
||||
)
|
||||
|
||||
export const jwks = pgTable('jwks', {
|
||||
id: text('id').primaryKey(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
privateKey: text('private_key').notNull(),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user