Skip to content

Commit d535b4c

Browse files
Validate OAuth scopes for MCP access
1 parent 1abb58d commit d535b4c

18 files changed

Lines changed: 336 additions & 40 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
ALTER TABLE "OAuthToken" ALTER COLUMN "scope" SET DEFAULT 'mcp';
2+
ALTER TABLE "OAuthRefreshToken" ALTER COLUMN "scope" SET DEFAULT 'mcp';
3+
4+
UPDATE "OAuthToken"
5+
SET "scope" = 'mcp'
6+
WHERE "scope" = '';
7+
8+
UPDATE "OAuthRefreshToken"
9+
SET "scope" = 'mcp'
10+
WHERE "scope" = '';

packages/db/prisma/schema.prisma

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ model OAuthRefreshToken {
665665
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
666666
userId String
667667
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
668-
scope String @default("")
668+
scope String @default("mcp")
669669
resource String? // RFC 8707
670670
expiresAt DateTime
671671
createdAt DateTime @default(now())
@@ -680,7 +680,7 @@ model OAuthToken {
680680
client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade)
681681
userId String
682682
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
683-
scope String @default("")
683+
scope String @default("mcp")
684684
resource String? // RFC 8707: canonical URI of the target resource server
685685
expiresAt DateTime
686686
createdAt DateTime @default(now())

packages/web/src/__mocks__/prisma.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[]
5353
hash: 'oauthtoken',
5454
clientId: 'test-client-id',
5555
userId: MOCK_USER_WITH_ACCOUNTS.id,
56-
scope: '',
56+
scope: 'mcp',
5757
resource: null,
5858
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now
5959
createdAt: new Date(),
@@ -65,10 +65,10 @@ export const MOCK_REFRESH_TOKEN: OAuthRefreshToken = {
6565
hash: 'refreshtoken',
6666
clientId: 'test-client-id',
6767
userId: MOCK_USER_WITH_ACCOUNTS.id,
68-
scope: '',
68+
scope: 'mcp',
6969
resource: null,
7070
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days from now
7171
createdAt: new Date(),
7272
}
7373

74-
export const userScopedPrismaClientExtension = vi.fn();
74+
export const userScopedPrismaClientExtension = vi.fn();

packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
22
import { env } from '@sourcebot/shared';
3-
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
3+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE, SOURCEBOT_OAUTH_SCOPES } from '@/ee/features/oauth/constants';
44
import { hasEntitlement } from '@/lib/entitlements';
55

66
// RFC 8414: OAuth 2.0 Authorization Server Metadata
@@ -24,6 +24,7 @@ export const GET = oauthApiHandler(async () => {
2424
revocation_endpoint: `${issuer}/api/ee/oauth/revoke`,
2525
response_types_supported: ['code'],
2626
grant_types_supported: ['authorization_code', 'refresh_token'],
27+
scopes_supported: SOURCEBOT_OAUTH_SCOPES,
2728
code_challenge_methods_supported: ['S256'],
2829
token_endpoint_auth_methods_supported: ['none'],
2930
service_documentation: 'https://docs.sourcebot.dev',

packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { env } from '@sourcebot/shared';
22
import { hasEntitlement } from '@/lib/entitlements';
33
import { NextRequest } from 'next/server';
4-
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
4+
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE, SOURCEBOT_OAUTH_SCOPES } from '@/ee/features/oauth/constants';
55
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
66

77
// RFC 9728: OAuth 2.0 Protected Resource Metadata (path-specific form)
@@ -37,5 +37,6 @@ export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { p
3737
authorization_servers: [
3838
issuer
3939
],
40+
scopes_supported: SOURCEBOT_OAUTH_SCOPES,
4041
});
4142
});

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { sew } from "@/middleware/sew";
1212
import { apiHandler } from '@/lib/apiHandler';
1313
import { env } from '@sourcebot/shared';
1414
import { hasEntitlement } from '@/lib/entitlements';
15+
import { SOURCEBOT_MCP_OAUTH_SCOPE } from '@/ee/features/oauth/constants';
1516

1617
// On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728)
1718
// so they can discover the authorization server and initiate the authorization code flow.
@@ -20,16 +21,31 @@ import { hasEntitlement } from '@/lib/entitlements';
2021
// @see: https://datatracker.ietf.org/doc/html/rfc9728
2122
async function mcpErrorResponse(error: ServiceError): Promise<Response> {
2223
const response = serviceErrorResponse(error);
23-
if (error.statusCode === StatusCodes.UNAUTHORIZED && await hasEntitlement('oauth')) {
24-
const issuer = env.AUTH_URL.replace(/\/$/, '');
25-
response.headers.set(
26-
'WWW-Authenticate',
27-
`Bearer realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`
28-
);
24+
if (
25+
(error.statusCode === StatusCodes.UNAUTHORIZED || error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) &&
26+
await hasEntitlement('oauth')
27+
) {
28+
response.headers.set('WWW-Authenticate', mcpBearerChallenge(error));
2929
}
3030
return response;
3131
}
3232

33+
function mcpBearerChallenge(error: ServiceError): string {
34+
const issuer = env.AUTH_URL.replace(/\/$/, '');
35+
const params = [
36+
'realm="Sourcebot"',
37+
`resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`,
38+
`scope="${SOURCEBOT_MCP_OAUTH_SCOPE}"`,
39+
];
40+
41+
if (error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) {
42+
params.push('error="insufficient_scope"');
43+
params.push(`error_description="${error.message}"`);
44+
}
45+
46+
return `Bearer ${params.join(', ')}`;
47+
}
48+
3349
// @see: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
3450
interface McpSession {
3551
server: McpServer;
@@ -95,7 +111,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
95111
await mcpServer.connect(transport);
96112

97113
return transport.handleRequest(request);
98-
})
114+
}, { requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] })
99115
);
100116

101117
if (isServiceError(response)) {
@@ -139,7 +155,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
139155
}
140156

141157
return session.transport.handleRequest(request);
142-
})
158+
}, { requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] })
143159
);
144160

145161
if (isServiceError(result)) {

packages/web/src/app/api/(server)/ee/oauth/token/route.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { verifyAndExchangeCode, verifyAndRotateRefreshToken } from '@/ee/features/oauth/server';
22
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
3-
import { env } from '@sourcebot/shared';
43
import { NextRequest } from 'next/server';
54
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
65
import { hasEntitlement } from '@/lib/entitlements';
@@ -61,8 +60,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
6160
access_token: result.token,
6261
refresh_token: result.refreshToken,
6362
token_type: 'Bearer',
64-
expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS,
65-
scope: '',
63+
expires_in: result.expiresIn,
64+
scope: result.scope,
6665
});
6766
}
6867

@@ -93,8 +92,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
9392
access_token: result.token,
9493
refresh_token: result.refreshToken,
9594
token_type: 'Bearer',
96-
expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS,
97-
scope: '',
95+
expires_in: result.expiresIn,
96+
scope: result.scope,
9897
});
9998
}
10099

packages/web/src/app/oauth/authorize/components/consentScreen.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface ConsentScreenProps {
1717
clientLogoUri: string | null;
1818
redirectUri: string;
1919
codeChallenge: string;
20+
requestedScope: string | undefined;
2021
resource: string | null;
2122
state: string | undefined;
2223
userEmail: string;
@@ -28,6 +29,7 @@ export function ConsentScreen({
2829
clientLogoUri,
2930
redirectUri,
3031
codeChallenge,
32+
requestedScope,
3133
resource,
3234
state,
3335
userEmail,
@@ -43,7 +45,7 @@ export function ConsentScreen({
4345
const onApprove = async () => {
4446
captureEvent('wa_oauth_authorization_approved', { clientId, clientName });
4547
setPending('approve');
46-
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, state });
48+
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, requestedScope, resource, state });
4749
if (!isServiceError(result)) {
4850
if (!isPermittedRedirectUrl(result)) {
4951
toast({ description: `❌ Redirect URL is not permitted.` });

packages/web/src/app/oauth/authorize/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ConsentScreen } from './components/consentScreen';
44
import { __unsafePrisma } from '@/prisma';
55
import { hasEntitlement } from '@/lib/entitlements';
66
import { redirect } from 'next/navigation';
7+
import { resolveGrantedOAuthScopes } from '@/ee/features/oauth/constants';
78

89
export const dynamic = 'force-dynamic';
910

@@ -15,6 +16,7 @@ interface AuthorizePageProps {
1516
code_challenge_method?: string;
1617
response_type?: string;
1718
state?: string;
19+
scope?: string;
1820
resource?: string | string[];
1921
}>;
2022
}
@@ -25,7 +27,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
2527
}
2628

2729
const params = await searchParams;
28-
const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, resource: _resource } = params;
30+
const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, scope, resource: _resource } = params;
2931

3032
// RFC 8707 allows multiple resource parameters to indicate a token intended for multiple resources.
3133
// Sourcebot only supports a single resource (the MCP endpoint), so we take the first value.
@@ -47,6 +49,11 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
4749
return <ErrorPage message={`Unsupported code_challenge_method: ${code_challenge_method}. Only "S256" is supported.`} />;
4850
}
4951

52+
const grantedScopes = resolveGrantedOAuthScopes(scope);
53+
if ('error' in grantedScopes) {
54+
return <ErrorPage message={grantedScopes.errorDescription} />;
55+
}
56+
5057
const client = await __unsafePrisma.oAuthClient.findUnique({ where: { id: client_id } });
5158

5259
if (!client) {
@@ -73,6 +80,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps
7380
clientLogoUri={client.logoUri}
7481
redirectUri={redirect_uri!}
7582
codeChallenge={code_challenge!}
83+
requestedScope={scope}
7684
resource={resource ?? null}
7785
state={state}
7886
userEmail={session!.user.email!}

packages/web/src/ee/features/oauth/actions.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import { sew } from "@/middleware/sew";
44
import { generateAndStoreAuthCode } from '@/ee/features/oauth/server';
55
import { withAuth } from '@/middleware/withAuth';
6-
import { UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants';
6+
import { resolveGrantedOAuthScopes, UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants';
7+
import { ErrorCode } from '@/lib/errorCodes';
8+
import type { ServiceError } from '@/lib/serviceError';
9+
import { StatusCodes } from 'http-status-codes';
710

811
export interface ConnectedOauthClient {
912
id: string;
@@ -37,16 +40,27 @@ export const approveAuthorization = async ({
3740
clientId,
3841
redirectUri,
3942
codeChallenge,
43+
requestedScope,
4044
resource,
4145
state,
4246
}: {
4347
clientId: string;
4448
redirectUri: string;
4549
codeChallenge: string;
50+
requestedScope: string | undefined;
4651
resource: string | null;
4752
state: string | undefined;
4853
}) => sew(() =>
4954
withAuth(async ({ user }) => {
55+
const grantedScopes = resolveGrantedOAuthScopes(requestedScope);
56+
if ('error' in grantedScopes) {
57+
return {
58+
statusCode: StatusCodes.BAD_REQUEST,
59+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
60+
message: grantedScopes.errorDescription,
61+
} satisfies ServiceError;
62+
}
63+
5064
const rawCode = await generateAndStoreAuthCode({
5165
clientId,
5266
userId: user.id,

0 commit comments

Comments
 (0)