diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8cffe95..692445e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- [EE] Avoid advertising OAuth support on MCP endpoints if that entitlement is not actually configured. [#985](https://github.com/sourcebot-dev/sourcebot/pull/985) + ## [4.15.1] - 2026-03-06 ### Fixed diff --git a/docs/docs/features/mcp-server.mdx b/docs/docs/features/mcp-server.mdx index e77e6e205..0316a9954 100644 --- a/docs/docs/features/mcp-server.mdx +++ b/docs/docs/features/mcp-server.mdx @@ -28,6 +28,10 @@ Sourcebot MCP uses a [Streamable HTTP](https://modelcontextprotocol.io/specifica You can read more about the options in the [authorization](#authorization) section. + + If [anonymous access](https://docs.sourcebot.dev/docs/configuration/auth/access-settings#anonymous-access) is enabled on your Sourcebot instance, no OAuth token or API key is required. You can connect directly to the MCP endpoint without any authorization. + + [Claude Code MCP docs](https://code.claude.com/docs/en/mcp#connect-claude-code-to-tools-via-mcp) @@ -273,7 +277,7 @@ You can read more about the options in the [authorization](#authorization) secti ## Authorization -The Sourcebot MCP server supports two authorization methods, OAuth and API keys. +The Sourcebot MCP server supports two authorization methods, OAuth and API keys. If [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) is enabled on your instance, no authorization is required. Regardless of which method you use, all MCP requests are scoped to the associated Sourcebot user and inherit the [user's role and permissions](/docs/configuration/auth/roles-and-permissions). When [permission syncing](/docs/features/permission-syncing) is configured, this includes repository permissions - the MCP server will only surface results from repositories the user has access to. diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 84a0b85ca..de1b8b1ee 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -37,6 +37,18 @@ const nextConfig = { source: "/.well-known/oauth-protected-resource/:path*", destination: "/api/ee/.well-known/oauth-protected-resource/:path*", }, + // Non-spec fallback: some MCP clients (observed in Claude Code and Cursor) guess + // /register as the Dynamic Client Registration endpoint (RFC 7591) when OAuth + // Authorization Server Metadata discovery fails entirely. This happens when the + // instance does not hold an enterprise license, causing all well-known endpoints + // to return 404. Per the MCP spec, clients should only attempt Dynamic Client + // Registration if the authorization server advertises a registration_endpoint in + // its metadata — guessing the URL is not spec-compliant behavior. This rewrite + // ensures those requests reach the actual endpoint rather than hitting a 404. + { + source: "/register", + destination: "/api/ee/oauth/register", + } ]; }, // This is required to support PostHog trailing slash API requests diff --git a/packages/web/src/app/api/(server)/[...slug]/route.ts b/packages/web/src/app/api/(server)/[...slug]/route.ts new file mode 100644 index 000000000..f98fdfd13 --- /dev/null +++ b/packages/web/src/app/api/(server)/[...slug]/route.ts @@ -0,0 +1,13 @@ +import { ErrorCode } from "@/lib/errorCodes" +import { serviceErrorResponse } from "@/lib/serviceError" +import { StatusCodes } from "http-status-codes" + +const handler = () => { + return serviceErrorResponse({ + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "This API endpoint does not exist", + }); +} + +export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE } \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts index 67c7e7e76..cdb71f642 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts @@ -1,11 +1,17 @@ import { apiHandler } from '@/lib/apiHandler'; -import { env } from '@sourcebot/shared'; +import { env, hasEntitlement } from '@sourcebot/shared'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // RFC 8414: OAuth 2.0 Authorization Server Metadata -// @note: we do not gate on entitlements here. That is handled in the /register, -// /token, and /revoke routes. // @see: https://datatracker.ietf.org/doc/html/rfc8414 export const GET = apiHandler(async () => { + if (!hasEntitlement('oauth')) { + return Response.json( + { error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, + { status: 404 } + ); + } + const issuer = env.AUTH_URL.replace(/\/$/, ''); return Response.json({ diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts index 6dd1055ff..2ec902530 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts @@ -1,17 +1,23 @@ import { apiHandler } from '@/lib/apiHandler'; -import { env } from '@sourcebot/shared'; +import { env, hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // RFC 9728: OAuth 2.0 Protected Resource Metadata (path-specific form) // For a resource at /api/mcp, the well-known URI is /.well-known/oauth-protected-resource/api/mcp. -// @note: we do not gate on entitlements here. That is handled in the /register, -// /token, and /revoke routes. // @see: https://datatracker.ietf.org/doc/html/rfc9728#section-3 const PROTECTED_RESOURCES = new Set([ 'api/mcp' ]); export const GET = apiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => { + if (!hasEntitlement('oauth')) { + return Response.json( + { error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, + { status: 404 } + ); + } + const { path } = await params; const resourcePath = path.join('/'); diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts deleted file mode 100644 index f311b400d..000000000 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { apiHandler } from '@/lib/apiHandler'; -import { env } from '@sourcebot/shared'; - -// RFC 9728: OAuth 2.0 Protected Resource Metadata -// Tells OAuth clients which authorization server protects this resource. -// @see: https://datatracker.ietf.org/doc/html/rfc9728 -export const GET = apiHandler(async () => { - const issuer = env.AUTH_URL.replace(/\/$/, ''); - - return Response.json({ - resource: `${issuer}/api/mcp`, - authorization_servers: [ - issuer - ], - }); -}, { track: false }); diff --git a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts index b9f969ae9..932ef5318 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts @@ -4,6 +4,7 @@ import { prisma } from '@/prisma'; import { hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; import { z } from 'zod'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // RFC 7591: OAuth 2.0 Dynamic Client Registration // @see: https://datatracker.ietf.org/doc/html/rfc7591 @@ -16,7 +17,7 @@ const registerRequestSchema = z.object({ export const POST = apiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( - { error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' }, + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 403 } ); } diff --git a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts index e82c17097..69d98db95 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts @@ -2,6 +2,7 @@ import { revokeToken } from '@/ee/features/oauth/server'; import { apiHandler } from '@/lib/apiHandler'; import { hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // RFC 7009: OAuth 2.0 Token Revocation // Always returns 200 regardless of whether the token existed. @@ -9,7 +10,7 @@ import { NextRequest } from 'next/server'; export const POST = apiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( - { error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' }, + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 403 } ); } diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts index e91b4726d..5638b10fd 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts @@ -2,6 +2,7 @@ import { verifyAndExchangeCode, verifyAndRotateRefreshToken, ACCESS_TOKEN_TTL_SE import { apiHandler } from '@/lib/apiHandler'; import { hasEntitlement } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; // OAuth 2.0 Token Endpoint // Supports grant_type=authorization_code with PKCE (RFC 7636). @@ -9,7 +10,7 @@ import { NextRequest } from 'next/server'; export const POST = apiHandler(async (request: NextRequest) => { if (!hasEntitlement('oauth')) { return Response.json( - { error: 'access_denied', error_description: 'OAuth is not available on this plan. Please see https://sourcebot.dev/pricing' }, + { error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE }, { status: 403 } ); } diff --git a/packages/web/src/app/api/(server)/mcp/route.ts b/packages/web/src/app/api/(server)/mcp/route.ts index e2b6789d0..a63148f56 100644 --- a/packages/web/src/app/api/(server)/mcp/route.ts +++ b/packages/web/src/app/api/(server)/mcp/route.ts @@ -9,7 +9,7 @@ import { StatusCodes } from 'http-status-codes'; import { NextRequest } from 'next/server'; import { sew } from '@/actions'; import { apiHandler } from '@/lib/apiHandler'; -import { env } from '@sourcebot/shared'; +import { env, hasEntitlement } from '@sourcebot/shared'; // On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728) // so they can discover the authorization server and initiate the authorization code flow. @@ -18,7 +18,7 @@ import { env } from '@sourcebot/shared'; // @see: https://datatracker.ietf.org/doc/html/rfc9728 function mcpErrorResponse(error: ServiceError): Response { const response = serviceErrorResponse(error); - if (error.statusCode === StatusCodes.UNAUTHORIZED) { + if (error.statusCode === StatusCodes.UNAUTHORIZED && hasEntitlement('oauth')) { const issuer = env.AUTH_URL.replace(/\/$/, ''); response.headers.set( 'WWW-Authenticate', diff --git a/packages/web/src/ee/features/oauth/constants.ts b/packages/web/src/ee/features/oauth/constants.ts new file mode 100644 index 000000000..cb79b7077 --- /dev/null +++ b/packages/web/src/ee/features/oauth/constants.ts @@ -0,0 +1,2 @@ + +export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this instance. Please authenticate using a Sourcebot API key instead. See https://docs.sourcebot.dev/docs/features/mcp-server for more information.'; \ No newline at end of file diff --git a/packages/web/src/proxy.ts b/packages/web/src/proxy.ts index 8d66f595f..f8c71c363 100644 --- a/packages/web/src/proxy.ts +++ b/packages/web/src/proxy.ts @@ -31,6 +31,6 @@ export async function proxy(request: NextRequest) { export const config = { // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher matcher: [ - '/((?!api|_next/static|.well-known/oauth-authorization-server|.well-known/oauth-protected-resource|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt|manifest.json|logo_192.png|logo_512.png|sb_logo_light_large.png|arrow.png|placeholder_avatar.png|sb_logo_dark_small.png|sb_logo_light_small.png).*)', + '/((?!api|_next/static|.well-known/oauth-authorization-server|.well-known/oauth-protected-resource|register|ingest|_next/image|favicon.ico|sitemap.xml|robots.txt|manifest.json|logo_192.png|logo_512.png|sb_logo_light_large.png|arrow.png|placeholder_avatar.png|sb_logo_dark_small.png|sb_logo_light_small.png).*)', ], } \ No newline at end of file