Skip to content

Commit ff4b389

Browse files
chore(web): Validate OAuth scopes for MCP access (#1396)
* Validate OAuth scopes for MCP access * Add changelog entry for OAuth scope validation * update migration * wip * wip * wip * remove oauth scopes
1 parent e1cea11 commit ff4b389

23 files changed

Lines changed: 916 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- [EE] Fixed Ask Sourcebot mermaid diagrams overflowing their container by contain-fitting them to both width and height, and made revealing a diagram from the answer jump it into view instantly to avoid over/undershooting. [#1373](https://github.com/sourcebot-dev/sourcebot/pull/1373)
2626
- Verified GitHub review webhook deliveries before processing them. [#1378](https://github.com/sourcebot-dev/sourcebot/pull/1378)
2727
- Passed Zoekt index parameters via argv to preserve revision names with punctuation. [#1376](https://github.com/sourcebot-dev/sourcebot/pull/1376)
28+
- [EE] Validated OAuth bearer token scopes before allowing access to the Sourcebot MCP resource server. [#1396](https://github.com/sourcebot-dev/sourcebot/pull/1396)
2829

2930
## [5.0.4] - 2026-06-18
3031

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "scope" TEXT NOT NULL DEFAULT '';

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -732,6 +732,7 @@ model OAuthAuthorizationCode {
732732
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
733733
redirectUri String
734734
codeChallenge String // BASE64URL(SHA-256(codeVerifier))
735+
scope String @default("")
735736
resource String? // RFC 8707: canonical URI of the target resource server
736737
dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding
737738
expiresAt DateTime

packages/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"openapi:generate": "tsx tools/generateOpenApi.ts",
1212
"generate:protos": "proto-loader-gen-types --includeComments --longs=Number --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --keepCase --includeDirs=../../vendor/zoekt/grpc/protos --outDir=src/proto zoekt/webserver/v1/webserver.proto zoekt/webserver/v1/query.proto",
1313
"dev:emails": "email dev --dir ./src/emails",
14-
"tool:decrypt-jwe": "tsx tools/decryptJWE.ts"
14+
"tool:decrypt-jwe": "tsx tools/decryptJWE.ts",
15+
"tool:oauth-flow": "tsx tools/oauthFlow.ts"
1516
},
1617
"dependencies": {
1718
"@ai-sdk/amazon-bedrock": "^4.0.94",

packages/web/src/__mocks__/prisma.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export const MOCK_ORG: Org = {
1717
updatedAt: new Date(),
1818
isOnboarded: true,
1919
imageUrl: null,
20-
metadata: null,
2120
memberApprovalRequired: false,
2221
isCredentialsLoginEnabled: true,
2322
isEmailCodeLoginEnabled: false,

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
import { SUPPORTED_DPOP_SIGNING_ALGS } from '@/ee/features/oauth/dpop';
66

@@ -25,6 +25,7 @@ export const GET = oauthApiHandler(async () => {
2525
revocation_endpoint: `${issuer}/api/ee/oauth/revoke`,
2626
response_types_supported: ['code'],
2727
grant_types_supported: ['authorization_code', 'refresh_token'],
28+
scopes_supported: SOURCEBOT_OAUTH_SCOPES,
2829
code_challenge_methods_supported: ['S256'],
2930
token_endpoint_auth_methods_supported: ['none'],
3031
dpop_signing_alg_values_supported: SUPPORTED_DPOP_SIGNING_ALGS,

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,6 +37,7 @@ export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { p
3737
authorization_servers: [
3838
issuer
3939
],
40+
scopes_supported: SOURCEBOT_OAUTH_SCOPES,
4041
bearer_methods_supported: ['header'],
4142
});
4243
});

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

Lines changed: 26 additions & 10 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_OAUTH_SCOPES } 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,20 +21,35 @@ 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.append(
26-
'WWW-Authenticate',
27-
`Bearer realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`
28-
);
29-
response.headers.append(
30-
'WWW-Authenticate',
31-
`DPoP realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`
32-
);
24+
if (
25+
(error.statusCode === StatusCodes.UNAUTHORIZED || error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) &&
26+
await hasEntitlement('oauth')
27+
) {
28+
response.headers.append('WWW-Authenticate', mcpOAuthChallenge('Bearer', error));
29+
response.headers.append('WWW-Authenticate', mcpOAuthChallenge('DPoP', error));
3330
}
3431
return response;
3532
}
3633

34+
function mcpOAuthChallenge(scheme: 'Bearer' | 'DPoP', error: ServiceError): string {
35+
const issuer = env.AUTH_URL.replace(/\/$/, '');
36+
const params = [
37+
'realm="Sourcebot"',
38+
`resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`,
39+
];
40+
const scope = SOURCEBOT_OAUTH_SCOPES.join(' ');
41+
if (scope) {
42+
params.push(`scope="${scope}"`);
43+
}
44+
45+
if (error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) {
46+
params.push('error="insufficient_scope"');
47+
params.push(`error_description="${error.message}"`);
48+
}
49+
50+
return `${scheme} ${params.join(', ')}`;
51+
}
52+
3753
// @see: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management
3854
interface McpSession {
3955
server: McpServer;

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';
@@ -77,8 +76,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
7776
access_token: result.token,
7877
refresh_token: result.refreshToken,
7978
token_type: result.dpopJkt ? DPOP_TOKEN_TYPE : 'Bearer',
80-
expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS,
81-
scope: '',
79+
expires_in: result.expiresIn,
80+
scope: result.scope,
8281
});
8382
}
8483

@@ -110,8 +109,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => {
110109
access_token: result.token,
111110
refresh_token: result.refreshToken,
112111
token_type: result.dpopJkt ? DPOP_TOKEN_TYPE : 'Bearer',
113-
expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS,
114-
scope: '',
112+
expires_in: result.expiresIn,
113+
scope: result.scope,
115114
});
116115
}
117116

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

33
import { approveAuthorization, denyAuthorization } from '@/ee/features/oauth/actions';
4-
import { isPermittedRedirectUrl } from '@/ee/features/oauth/constants';
4+
import { isPermittedRedirectUrl } from '@/ee/features/oauth/utils';
55
import { LoadingButton } from '@/components/ui/loading-button';
66
import { isServiceError } from '@/lib/utils';
77
import { ClientIcon } from './clientIcon';
@@ -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
dpopJkt: string | null;
2223
state: string | undefined;
@@ -29,6 +30,7 @@ export function ConsentScreen({
2930
clientLogoUri,
3031
redirectUri,
3132
codeChallenge,
33+
requestedScope,
3234
resource,
3335
dpopJkt,
3436
state,
@@ -45,7 +47,7 @@ export function ConsentScreen({
4547
const onApprove = async () => {
4648
captureEvent('wa_oauth_authorization_approved', { clientId, clientName });
4749
setPending('approve');
48-
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, dpopJkt, state });
50+
const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, requestedScope, resource, dpopJkt, state });
4951
if (!isServiceError(result)) {
5052
if (!isPermittedRedirectUrl(result)) {
5153
toast({ description: `❌ Redirect URL is not permitted.` });

0 commit comments

Comments
 (0)