Skip to content

Commit fe37d4f

Browse files
feat(web): [EE] OAuth2 authorization server with MCP support (#977)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f7ba084 commit fe37d4f

File tree

31 files changed

+1516
-47
lines changed

31 files changed

+1516
-47
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Added support a MCP streamable http transport hosted at `/api/mcp`. [#976](https://github.com/sourcebot-dev/sourcebot/pull/976)
12+
- [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)
1213

1314
## [4.13.2] - 2026-03-02
1415

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,4 @@ After the PR is created:
237237
- Update CHANGELOG.md with an entry under `[Unreleased]` linking to the new PR. New entries should be placed at the bottom of their section.
238238
- If the change touches `packages/mcp`, update `packages/mcp/CHANGELOG.md` instead
239239
- Do NOT add a CHANGELOG entry for documentation-only changes (e.g., changes only in `docs/`)
240+
- Enterprise-only features (gated by an entitlement) should be prefixed with `[EE]` in the CHANGELOG entry (e.g., `- [EE] Added support for ...`)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
-- CreateTable
2+
CREATE TABLE "OAuthClient" (
3+
"id" TEXT NOT NULL,
4+
"name" TEXT NOT NULL,
5+
"redirectUris" TEXT[],
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
8+
CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- CreateTable
12+
CREATE TABLE "OAuthAuthorizationCode" (
13+
"codeHash" TEXT NOT NULL,
14+
"clientId" TEXT NOT NULL,
15+
"userId" TEXT NOT NULL,
16+
"redirectUri" TEXT NOT NULL,
17+
"codeChallenge" TEXT NOT NULL,
18+
"expiresAt" TIMESTAMP(3) NOT NULL,
19+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20+
21+
CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("codeHash")
22+
);
23+
24+
-- CreateTable
25+
CREATE TABLE "OAuthToken" (
26+
"hash" TEXT NOT NULL,
27+
"clientId" TEXT NOT NULL,
28+
"userId" TEXT NOT NULL,
29+
"scope" TEXT NOT NULL DEFAULT '',
30+
"expiresAt" TIMESTAMP(3) NOT NULL,
31+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
32+
"lastUsedAt" TIMESTAMP(3),
33+
34+
CONSTRAINT "OAuthToken_pkey" PRIMARY KEY ("hash")
35+
);
36+
37+
-- AddForeignKey
38+
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
39+
40+
-- AddForeignKey
41+
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
42+
43+
-- AddForeignKey
44+
ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
45+
46+
-- AddForeignKey
47+
ALTER TABLE "OAuthToken" ADD CONSTRAINT "OAuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "OAuthClient" ADD COLUMN "logoUri" TEXT;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "resource" TEXT;
3+
4+
-- AlterTable
5+
ALTER TABLE "OAuthToken" ADD COLUMN "resource" TEXT;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- CreateTable
2+
CREATE TABLE "OAuthRefreshToken" (
3+
"hash" TEXT NOT NULL,
4+
"clientId" TEXT NOT NULL,
5+
"userId" TEXT NOT NULL,
6+
"scope" TEXT NOT NULL DEFAULT '',
7+
"resource" TEXT,
8+
"expiresAt" TIMESTAMP(3) NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("hash")
12+
);
13+
14+
-- CreateIndex
15+
CREATE INDEX "OAuthRefreshToken_clientId_userId_idx" ON "OAuthRefreshToken"("clientId", "userId");
16+
17+
-- AddForeignKey
18+
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "OAuthClient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19+
20+
-- AddForeignKey
21+
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

packages/db/prisma/schema.prisma

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,10 @@ model User {
365365
chats Chat[]
366366
sharedChats ChatAccess[]
367367
368+
oauthTokens OAuthToken[]
369+
oauthAuthCodes OAuthAuthorizationCode[]
370+
oauthRefreshTokens OAuthRefreshToken[]
371+
368372
createdAt DateTime @default(now())
369373
updatedAt DateTime @updatedAt
370374
@@ -485,3 +489,64 @@ model ChatAccess {
485489
486490
@@unique([chatId, userId])
487491
}
492+
493+
// OAuth2 Authorization Server models
494+
// @see: https://datatracker.ietf.org/doc/html/rfc6749
495+
496+
/// A registered OAuth2 client application (e.g. Claude Desktop, Cursor).
497+
/// Created via dynamic client registration (RFC 7591) at POST /api/ee/oauth/register.
498+
model OAuthClient {
499+
id String @id @default(cuid())
500+
name String
501+
logoUri String?
502+
redirectUris String[]
503+
createdAt DateTime @default(now())
504+
505+
authCodes OAuthAuthorizationCode[]
506+
tokens OAuthToken[]
507+
refreshTokens OAuthRefreshToken[]
508+
}
509+
510+
/// A short-lived authorization code issued during the OAuth2 authorization code flow.
511+
/// Single-use and expires after 10 minutes. Stores the PKCE code challenge.
512+
model OAuthAuthorizationCode {
513+
codeHash String @id // hashSecret(rawCode)
514+
clientId String
515+
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
516+
userId String
517+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
518+
redirectUri String
519+
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
520+
resource String? // RFC 8707: canonical URI of the target resource server
521+
expiresAt DateTime
522+
createdAt DateTime @default(now())
523+
}
524+
525+
/// An opaque OAuth2 refresh token. Single-use with rotation (RFC 6749 Section 10.4, OAuth 2.1 Section 4.3.1).
526+
model OAuthRefreshToken {
527+
hash String @id // hashSecret(rawToken secret portion)
528+
clientId String
529+
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
530+
userId String
531+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
532+
scope String @default("")
533+
resource String? // RFC 8707
534+
expiresAt DateTime
535+
createdAt DateTime @default(now())
536+
537+
@@index([clientId, userId])
538+
}
539+
540+
/// An opaque OAuth2 access token. The raw token is never stored — only its HMAC-SHA256 hash.
541+
model OAuthToken {
542+
hash String @id // hashSecret(rawToken secret portion)
543+
clientId String
544+
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
545+
userId String
546+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
547+
scope String @default("")
548+
resource String? // RFC 8707: canonical URI of the target resource server
549+
expiresAt DateTime
550+
createdAt DateTime @default(now())
551+
lastUsedAt DateTime?
552+
}

packages/shared/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { ConfigSettings, IdentityProviderType } from "./types.js";
33

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

6+
/**
7+
* @deprecated Use API_KEY_PREFIX instead.
8+
*/
9+
export const LEGACY_API_KEY_PREFIX = 'sourcebot-';
10+
11+
export const API_KEY_PREFIX = 'sbk_';
12+
export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_';
13+
export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_';
14+
615
export const SOURCEBOT_UNLIMITED_SEATS = -1;
716

817
/**

packages/shared/src/crypto.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod';
44
import { env } from './env.server.js';
55
import { Token } from '@sourcebot/schemas/v3/shared.type';
66
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
7+
import { API_KEY_PREFIX, OAUTH_ACCESS_TOKEN_PREFIX, OAUTH_REFRESH_TOKEN_PREFIX } from './constants.js';
78

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

3738
return {
38-
key: `sourcebot-${secret}`,
39+
key: `${API_KEY_PREFIX}${secret}`,
40+
hash,
41+
};
42+
}
43+
44+
export function generateOAuthToken(): { token: string; hash: string } {
45+
const secret = crypto.randomBytes(32).toString('hex');
46+
const hash = hashSecret(secret);
47+
48+
return {
49+
token: `${OAUTH_ACCESS_TOKEN_PREFIX}${secret}`,
50+
hash,
51+
};
52+
}
53+
54+
export function generateOAuthRefreshToken(): { token: string; hash: string } {
55+
const secret = crypto.randomBytes(32).toString('hex');
56+
const hash = hashSecret(secret);
57+
58+
return {
59+
token: `${OAUTH_REFRESH_TOKEN_PREFIX}${secret}`,
3960
hash,
4061
};
4162
}

packages/shared/src/entitlements.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ const entitlements = [
4040
"permission-syncing",
4141
"github-app",
4242
"chat-sharing",
43-
"org-management"
43+
"org-management",
44+
"oauth",
4445
] as const;
4546
export type Entitlement = (typeof entitlements)[number];
4647

@@ -58,6 +59,7 @@ const entitlementsByPlan: Record<Plan, Entitlement[]> = {
5859
"github-app",
5960
"chat-sharing",
6061
"org-management",
62+
"oauth",
6163
],
6264
"self-hosted:enterprise-unlimited": [
6365
"anonymous-access",
@@ -70,6 +72,7 @@ const entitlementsByPlan: Record<Plan, Entitlement[]> = {
7072
"github-app",
7173
"chat-sharing",
7274
"org-management",
75+
"oauth",
7376
],
7477
} as const;
7578

0 commit comments

Comments
 (0)