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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added
- Added support a MCP streamable http transport hosted at `/api/mcp`. [#976](https://github.com/sourcebot-dev/sourcebot/pull/976)
- [EE] Added support for Oauth 2.1 to the remote MCP server hosted at `/api/mcp`. [#977](https://github.com/sourcebot-dev/sourcebot/pull/977)

## [4.13.2] - 2026-03-02

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,4 @@ After the PR is created:
- Update CHANGELOG.md with an entry under `[Unreleased]` linking to the new PR. New entries should be placed at the bottom of their section.
- If the change touches `packages/mcp`, update `packages/mcp/CHANGELOG.md` instead
- Do NOT add a CHANGELOG entry for documentation-only changes (e.g., changes only in `docs/`)
- Enterprise-only features (gated by an entitlement) should be prefixed with `[EE]` in the CHANGELOG entry (e.g., `- [EE] Added support for ...`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "OAuthClient" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"redirectUris" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "OAuthAuthorizationCode" (
"codeHash" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"redirectUri" TEXT NOT NULL,
"codeChallenge" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("codeHash")
);

-- CreateTable
CREATE TABLE "OAuthToken" (
"hash" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT NOT NULL DEFAULT '',
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3),

CONSTRAINT "OAuthToken_pkey" PRIMARY KEY ("hash")
);

-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OAuthClient" ADD COLUMN "logoUri" TEXT;
46 changes: 46 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,9 @@ model User {
chats Chat[]
sharedChats ChatAccess[]

oauthTokens OAuthToken[]
oauthAuthCodes OAuthAuthorizationCode[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down Expand Up @@ -485,3 +488,46 @@ model ChatAccess {

@@unique([chatId, userId])
}

// OAuth2 Authorization Server models
// @see: https://datatracker.ietf.org/doc/html/rfc6749

/// A registered OAuth2 client application (e.g. Claude Desktop, Cursor).
/// Created via dynamic client registration (RFC 7591) at POST /api/oauth/register.
model OAuthClient {
id String @id @default(cuid())
name String
logoUri String?
redirectUris String[]
createdAt DateTime @default(now())

authCodes OAuthAuthorizationCode[]
tokens OAuthToken[]
}

/// A short-lived authorization code issued during the OAuth2 authorization code flow.
/// Single-use and expires after 10 minutes. Stores the PKCE code challenge.
model OAuthAuthorizationCode {
codeHash String @id // hashSecret(rawCode)
clientId String
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
redirectUri String
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
expiresAt DateTime
createdAt DateTime @default(now())
}

/// An opaque OAuth2 access token. The raw token is never stored — only its HMAC-SHA256 hash.
model OAuthToken {
hash String @id // hashSecret(rawToken secret portion)
clientId String
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
scope String @default("")
expiresAt DateTime
createdAt DateTime @default(now())
lastUsedAt DateTime?
}
10 changes: 10 additions & 0 deletions packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ export function generateApiKey(): { key: string; hash: string } {
};
}

export function generateOAuthToken(): { token: string; hash: string } {
const secret = crypto.randomBytes(32).toString('hex');
const hash = hashSecret(secret);

return {
token: `sourcebot-oauth-${secret}`,
hash,
};
}

export function decrypt(iv: string, encryptedText: string): string {
const encryptionKey = Buffer.from(env.SOURCEBOT_ENCRYPTION_KEY, 'ascii');

Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ const entitlements = [
"permission-syncing",
"github-app",
"chat-sharing",
"org-management"
"org-management",
"oauth",
] as const;
export type Entitlement = (typeof entitlements)[number];

Expand All @@ -58,6 +59,7 @@ const entitlementsByPlan: Record<Plan, Entitlement[]> = {
"github-app",
"chat-sharing",
"org-management",
"oauth",
],
"self-hosted:enterprise-unlimited": [
"anonymous-access",
Expand All @@ -70,6 +72,7 @@ const entitlementsByPlan: Record<Plan, Entitlement[]> = {
"github-app",
"chat-sharing",
"org-management",
"oauth",
],
} as const;

Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export {
decrypt,
hashSecret,
generateApiKey,
generateOAuthToken,
verifySignature,
encryptOAuthToken,
decryptOAuthToken,
Expand Down
13 changes: 13 additions & 0 deletions packages/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ const nextConfig = {
source: "/ingest/decide",
destination: `https://us.i.posthog.com/decide`,
},
// Expose OAuth discovery documents at canonical RFC paths (without /api/ee prefix)
// so MCP clients and OAuth tools can find them via standard discovery.
//
// RFC 8414: /.well-known/oauth-authorization-server
{
source: "/.well-known/oauth-authorization-server",
destination: "/api/ee/.well-known/oauth-authorization-server",
},
// RFC 9728: path-specific form /.well-known/oauth-protected-resource/{resource-path}
{
source: "/.well-known/oauth-protected-resource/:path*",
destination: "/api/ee/.well-known/oauth-protected-resource/:path*",
},
];
},
// This is required to support PostHog trailing slash API requests
Expand Down
13 changes: 12 additions & 1 deletion packages/web/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
import { Account, ApiKey, Org, PrismaClient, User } from '@prisma/client';
import { Account, ApiKey, OAuthToken, Org, PrismaClient, User } from '@prisma/client';
import { beforeEach, vi } from 'vitest';
import { mockDeep, mockReset } from 'vitest-mock-extended';

Expand Down Expand Up @@ -47,4 +47,15 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
accounts: [],
}

export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[] } } = {
hash: 'oauthtoken',
clientId: 'test-client-id',
userId: MOCK_USER_WITH_ACCOUNTS.id,
scope: '',
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year from now
createdAt: new Date(),
lastUsedAt: null,
user: MOCK_USER_WITH_ACCOUNTS,
}

export const userScopedPrismaClientExtension = vi.fn();
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { apiHandler } from '@/lib/apiHandler';
import { env } from '@sourcebot/shared';

// 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 () => {
const issuer = env.AUTH_URL.replace(/\/$/, '');

return Response.json({
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/api/ee/oauth/token`,
registration_endpoint: `${issuer}/api/ee/oauth/register`,
revocation_endpoint: `${issuer}/api/ee/oauth/revoke`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { apiHandler } from '@/lib/apiHandler';
import { env } from '@sourcebot/shared';
import { NextRequest } from 'next/server';

// 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[] }> }) => {
const { path } = await params;
const resourcePath = path.join('/');

if (!PROTECTED_RESOURCES.has(resourcePath)) {
return Response.json(
{ error: 'not_found', error_description: `No protected resource metadata found for path: ${resourcePath}` },
{ status: 404 }
);
}

const issuer = env.AUTH_URL.replace(/\/$/, '');

return Response.json({
resource: `${issuer}/${resourcePath}`,
authorization_servers: [
issuer
],
});
});
58 changes: 58 additions & 0 deletions packages/web/src/app/api/(server)/ee/oauth/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { apiHandler } from '@/lib/apiHandler';
import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
import { prisma } from '@/prisma';
import { hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { z } from 'zod';

// RFC 7591: OAuth 2.0 Dynamic Client Registration
// @see: https://datatracker.ietf.org/doc/html/rfc7591
const registerRequestSchema = z.object({
client_name: z.string().min(1),
redirect_uris: z.array(z.string().url()).min(1),
logo_uri: z.string().nullish(),
});

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.' },
{ status: 403 }
);
}

const body = await request.json();
const parsed = registerRequestSchema.safeParse(body);

if (!parsed.success) {
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
}

const { client_name, redirect_uris, logo_uri } = parsed.data;

// Reject wildcard redirect URIs per security best practices
if (redirect_uris.some((uri) => uri.includes('*'))) {
return Response.json(
{ error: 'invalid_redirect_uri', error_description: 'Wildcard redirect URIs are not allowed.' },
{ status: 400 }
);
}

const client = await prisma.oAuthClient.create({
data: {
name: client_name,
logoUri: logo_uri ?? null,
redirectUris: redirect_uris,
},
});

return Response.json(
{
client_id: client.id,
client_name: client.name,
...(client.logoUri && { logo_uri: client.logoUri }),
redirect_uris: client.redirectUris,
},
{ status: 201 }
);
});
25 changes: 25 additions & 0 deletions packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { revokeToken } from '@/features/oauth/server';
import { apiHandler } from '@/lib/apiHandler';
import { hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';

// RFC 7009: OAuth 2.0 Token Revocation
// Always returns 200 regardless of whether the token existed.
// @see: https://datatracker.ietf.org/doc/html/rfc7009
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.' },
{ status: 403 }
);
}

const formData = await request.formData();
const token = formData.get('token');

if (token) {
await revokeToken(token.toString());
}

return new Response(null, { status: 200 });
});
Loading