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:
Waleed
2026-02-20 15:56:15 -08:00
committed by GitHub
parent 1b8d666c93
commit 3fa4bb4c12
13 changed files with 12530 additions and 20 deletions

View 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'>&#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>
)
}

View File

@@ -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

View 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',
})
}

View File

@@ -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 })

View File

@@ -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
}),

View 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' }
}
}

View File

@@ -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 },
},
]

View File

@@ -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(
{

View File

@@ -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',
},
],
},

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -1093,6 +1093,13 @@
"when": 1771528429740,
"tag": "0156_easy_odin",
"breakpoints": true
},
{
"idx": 157,
"version": "7",
"when": 1771621587420,
"tag": "0157_exotic_dormammu",
"breakpoints": true
}
]
}

View File

@@ -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(),
})