Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "resource" TEXT;

-- AlterTable
ALTER TABLE "OAuthToken" ADD COLUMN "resource" TEXT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "OAuthRefreshToken" (
"hash" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scope" TEXT NOT NULL DEFAULT '',
"resource" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

-- CreateIndex
CREATE INDEX "OAuthRefreshToken_clientId_userId_idx" ON "OAuthRefreshToken"("clientId", "userId");

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

-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
65 changes: 65 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ model User {
chats Chat[]
sharedChats ChatAccess[]

oauthTokens OAuthToken[]
oauthAuthCodes OAuthAuthorizationCode[]
oauthRefreshTokens OAuthRefreshToken[]

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

Expand Down Expand Up @@ -485,3 +489,64 @@ 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/ee/oauth/register.
model OAuthClient {
id String @id @default(cuid())
name String
logoUri String?
redirectUris String[]
createdAt DateTime @default(now())

authCodes OAuthAuthorizationCode[]
tokens OAuthToken[]
refreshTokens OAuthRefreshToken[]
}

/// 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))
resource String? // RFC 8707: canonical URI of the target resource server
expiresAt DateTime
createdAt DateTime @default(now())
}

/// An opaque OAuth2 refresh token. Single-use with rotation (RFC 6749 Section 10.4, OAuth 2.1 Section 4.3.1).
model OAuthRefreshToken {
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("")
resource String? // RFC 8707
expiresAt DateTime
createdAt DateTime @default(now())

@@index([clientId, userId])
}

/// 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("")
resource String? // RFC 8707: canonical URI of the target resource server
expiresAt DateTime
createdAt DateTime @default(now())
lastUsedAt DateTime?
}
9 changes: 9 additions & 0 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { ConfigSettings, IdentityProviderType } from "./types.js";

export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';

/**
* @deprecated Use API_KEY_PREFIX instead.
*/
export const LEGACY_API_KEY_PREFIX = 'sourcebot-';

export const API_KEY_PREFIX = 'sbk_';
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';

export const SOURCEBOT_UNLIMITED_SEATS = -1;

/**
Expand Down
23 changes: 22 additions & 1 deletion packages/shared/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from 'zod';
import { env } from './env.server.js';
import { Token } from '@sourcebot/schemas/v3/shared.type';
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js';

const algorithm = 'aes-256-cbc';
const ivLength = 16; // 16 bytes for CBC
Expand Down Expand Up @@ -35,7 +36,27 @@ export function generateApiKey(): { key: string; hash: string } {
const hash = hashSecret(secret);

return {
key: `sourcebot-${secret}`,
key: `${API_KEY_PREFIX}${secret}`,
hash,
};
}

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

return {
token: `${OAUTH_ACCESS_TOKEN_PREFIX}${secret}`,
hash,
};
}

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

return {
token: `${OAUTH_REFRESH_TOKEN_PREFIX}${secret}`,
hash,
};
}
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
2 changes: 2 additions & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export {
decrypt,
hashSecret,
generateApiKey,
generateOAuthToken,
generateOAuthRefreshToken,
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
24 changes: 23 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, OAuthRefreshToken, 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,26 @@ 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: '',
resource: null,
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now
createdAt: new Date(),
lastUsedAt: null,
user: MOCK_USER_WITH_ACCOUNTS,
}

export const MOCK_REFRESH_TOKEN: OAuthRefreshToken = {
hash: 'refreshtoken',
clientId: 'test-client-id',
userId: MOCK_USER_WITH_ACCOUNTS.id,
scope: '',
resource: null,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days from now
createdAt: new Date(),
}

export const userScopedPrismaClientExtension = vi.fn();
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
service_documentation: 'https://docs.sourcebot.dev',
});
});
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
],
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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 });
Loading