Skip to content

Commit 871ee1b

Browse files
wip on oauth2 support over mcp
1 parent f7ba084 commit 871ee1b

File tree

16 files changed

+559
-39
lines changed

16 files changed

+559
-39
lines changed
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;

packages/db/prisma/schema.prisma

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,9 @@ model User {
365365
chats Chat[]
366366
sharedChats ChatAccess[]
367367
368+
oauthTokens OAuthToken[]
369+
oauthAuthCodes OAuthAuthorizationCode[]
370+
368371
createdAt DateTime @default(now())
369372
updatedAt DateTime @updatedAt
370373
@@ -485,3 +488,45 @@ model ChatAccess {
485488
486489
@@unique([chatId, userId])
487490
}
491+
492+
// OAuth2 Authorization Server models
493+
// @see: https://datatracker.ietf.org/doc/html/rfc6749
494+
495+
/// A registered OAuth2 client application (e.g. Claude Desktop, Cursor).
496+
/// Created via dynamic client registration (RFC 7591) at POST /api/oauth/register.
497+
model OAuthClient {
498+
id String @id @default(cuid())
499+
name String
500+
redirectUris String[]
501+
createdAt DateTime @default(now())
502+
503+
authCodes OAuthAuthorizationCode[]
504+
tokens OAuthToken[]
505+
}
506+
507+
/// A short-lived authorization code issued during the OAuth2 authorization code flow.
508+
/// Single-use and expires after 10 minutes. Stores the PKCE code challenge.
509+
model OAuthAuthorizationCode {
510+
codeHash String @id // hashSecret(rawCode)
511+
clientId String
512+
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
513+
userId String
514+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
515+
redirectUri String
516+
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
517+
expiresAt DateTime
518+
createdAt DateTime @default(now())
519+
}
520+
521+
/// An opaque OAuth2 access token. The raw token is never stored — only its HMAC-SHA256 hash.
522+
model OAuthToken {
523+
hash String @id // hashSecret(rawToken secret portion)
524+
clientId String
525+
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
526+
userId String
527+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
528+
scope String @default("")
529+
expiresAt DateTime
530+
createdAt DateTime @default(now())
531+
lastUsedAt DateTime?
532+
}

packages/shared/src/crypto.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export function generateApiKey(): { key: string; hash: string } {
4040
};
4141
}
4242

43+
export function generateOAuthToken(): { token: string; hash: string } {
44+
const secret = crypto.randomBytes(32).toString('hex');
45+
const hash = hashSecret(secret);
46+
47+
return {
48+
token: `sourcebot-oauth-${secret}`,
49+
hash,
50+
};
51+
}
52+
4353
export function decrypt(iv: string, encryptedText: string): string {
4454
const encryptionKey = Buffer.from(env.SOURCEBOT_ENCRYPTION_KEY, 'ascii');
4555

packages/shared/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export {
4545
decrypt,
4646
hashSecret,
4747
generateApiKey,
48+
generateOAuthToken,
4849
verifySignature,
4950
encryptOAuthToken,
5051
decryptOAuthToken,

packages/web/next.config.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ const nextConfig = {
2424
source: "/ingest/decide",
2525
destination: `https://us.i.posthog.com/decide`,
2626
},
27+
// Expose OAuth discovery documents at canonical RFC paths (without /api prefix)
28+
// so MCP clients and OAuth tools can find them via standard discovery.
29+
{
30+
source: "/.well-known/oauth-authorization-server",
31+
destination: "/api/.well-known/oauth-authorization-server",
32+
},
33+
{
34+
source: "/.well-known/oauth-protected-resource",
35+
destination: "/api/.well-known/oauth-protected-resource",
36+
},
2737
];
2838
},
2939
// This is required to support PostHog trailing slash API requests

packages/web/src/__mocks__/prisma.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
2-
import { Account, ApiKey, Org, PrismaClient, User } from '@prisma/client';
2+
import { Account, ApiKey, OAuthToken, Org, PrismaClient, User } from '@prisma/client';
33
import { beforeEach, vi } from 'vitest';
44
import { mockDeep, mockReset } from 'vitest-mock-extended';
55

@@ -47,4 +47,15 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = {
4747
accounts: [],
4848
}
4949

50+
export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[] } } = {
51+
hash: 'oauthtoken',
52+
clientId: 'test-client-id',
53+
userId: MOCK_USER_WITH_ACCOUNTS.id,
54+
scope: '',
55+
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), // 1 year from now
56+
createdAt: new Date(),
57+
lastUsedAt: null,
58+
user: MOCK_USER_WITH_ACCOUNTS,
59+
}
60+
5061
export const userScopedPrismaClientExtension = vi.fn();
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { apiHandler } from '@/lib/apiHandler';
2+
import { env } from '@sourcebot/shared';
3+
4+
// RFC 8414: OAuth 2.0 Authorization Server Metadata
5+
// @see: https://datatracker.ietf.org/doc/html/rfc8414
6+
export const GET = apiHandler(async () => {
7+
const issuer = env.AUTH_URL.replace(/\/$/, '');
8+
9+
return Response.json({
10+
issuer,
11+
authorization_endpoint: `${issuer}/oauth/authorize`,
12+
token_endpoint: `${issuer}/api/oauth/token`,
13+
registration_endpoint: `${issuer}/api/oauth/register`,
14+
revocation_endpoint: `${issuer}/api/oauth/revoke`,
15+
response_types_supported: ['code'],
16+
grant_types_supported: ['authorization_code'],
17+
code_challenge_methods_supported: ['S256'],
18+
token_endpoint_auth_methods_supported: ['none'],
19+
});
20+
}, { track: false });
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { apiHandler } from '@/lib/apiHandler';
2+
import { env } from '@sourcebot/shared';
3+
4+
// RFC 9728: OAuth 2.0 Protected Resource Metadata
5+
// Tells OAuth clients which authorization server protects this resource.
6+
// @see: https://datatracker.ietf.org/doc/html/rfc9728
7+
export const GET = apiHandler(async () => {
8+
const issuer = env.AUTH_URL.replace(/\/$/, '');
9+
10+
return Response.json({
11+
resource: `${issuer}/api/mcp`,
12+
authorization_servers: [issuer],
13+
});
14+
}, { track: false });

packages/web/src/app/api/(server)/mcp/route.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ import { StatusCodes } from 'http-status-codes';
99
import { NextRequest } from 'next/server';
1010
import { sew } from '@/actions';
1111
import { apiHandler } from '@/lib/apiHandler';
12+
import { env } from '@sourcebot/shared';
13+
14+
// On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728)
15+
// so they can discover the authorization server and initiate the authorization code flow.
16+
// @see: https://modelcontextprotocol.io/specification/2025-03-26/basic/authentication
17+
// @see: https://datatracker.ietf.org/doc/html/rfc9728
18+
function mcpErrorResponse(error: ServiceError): Response {
19+
const response = serviceErrorResponse(error);
20+
if (error.statusCode === StatusCodes.UNAUTHORIZED) {
21+
const issuer = env.AUTH_URL.replace(/\/$/, '');
22+
response.headers.set(
23+
'WWW-Authenticate',
24+
`Bearer realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource"`
25+
);
26+
}
27+
return response;
28+
}
1229

1330
// @see: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
1431
interface McpSession {
@@ -66,7 +83,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
6683
);
6784

6885
if (isServiceError(response)) {
69-
return serviceErrorResponse(response);
86+
return mcpErrorResponse(response);
7087
}
7188

7289
return response;
@@ -99,7 +116,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
99116
);
100117

101118
if (isServiceError(result)) {
102-
return serviceErrorResponse(result);
119+
return mcpErrorResponse(result);
103120
}
104121

105122
return result;
@@ -132,7 +149,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
132149
);
133150

134151
if (isServiceError(result)) {
135-
return serviceErrorResponse(result);
152+
return mcpErrorResponse(result);
136153
}
137154

138155
return result;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { apiHandler } from '@/lib/apiHandler';
2+
import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
3+
import { prisma } from '@/prisma';
4+
import { NextRequest } from 'next/server';
5+
import { z } from 'zod';
6+
7+
// RFC 7591: OAuth 2.0 Dynamic Client Registration
8+
// @see: https://datatracker.ietf.org/doc/html/rfc7591
9+
const registerRequestSchema = z.object({
10+
client_name: z.string().min(1),
11+
redirect_uris: z.array(z.string().url()).min(1),
12+
});
13+
14+
export const POST = apiHandler(async (request: NextRequest) => {
15+
const body = await request.json();
16+
const parsed = registerRequestSchema.safeParse(body);
17+
18+
if (!parsed.success) {
19+
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
20+
}
21+
22+
const { client_name, redirect_uris } = parsed.data;
23+
24+
// Reject wildcard redirect URIs per security best practices
25+
if (redirect_uris.some((uri) => uri.includes('*'))) {
26+
return Response.json(
27+
{ error: 'invalid_redirect_uri', error_description: 'Wildcard redirect URIs are not allowed.' },
28+
{ status: 400 }
29+
);
30+
}
31+
32+
const client = await prisma.oAuthClient.create({
33+
data: {
34+
name: client_name,
35+
redirectUris: redirect_uris,
36+
},
37+
});
38+
39+
return Response.json(
40+
{
41+
client_id: client.id,
42+
client_name: client.name,
43+
redirect_uris: client.redirectUris,
44+
},
45+
{ status: 201 }
46+
);
47+
});

0 commit comments

Comments
 (0)