From d535b4cd84818b473d6458c84660698c4e8ffcab Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 29 Jun 2026 13:48:14 -0700 Subject: [PATCH 1/7] Validate OAuth scopes for MCP access --- .../migration.sql | 10 +++ packages/db/prisma/schema.prisma | 4 +- packages/web/src/__mocks__/prisma.ts | 6 +- .../oauth-authorization-server/route.ts | 3 +- .../[...path]/route.ts | 3 +- .../web/src/app/api/(server)/ee/mcp/route.ts | 32 +++++-- .../app/api/(server)/ee/oauth/token/route.ts | 9 +- .../authorize/components/consentScreen.tsx | 4 +- packages/web/src/app/oauth/authorize/page.tsx | 10 ++- packages/web/src/ee/features/oauth/actions.ts | 16 +++- .../src/ee/features/oauth/constants.test.ts | 42 ++++++++- .../web/src/ee/features/oauth/constants.ts | 54 +++++++++++- .../web/src/ee/features/oauth/server.test.ts | 39 ++++++++ packages/web/src/ee/features/oauth/server.ts | 16 +++- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/serviceError.ts | 9 +- packages/web/src/middleware/withAuth.test.ts | 88 +++++++++++++++++++ packages/web/src/middleware/withAuth.ts | 30 ++++--- 18 files changed, 336 insertions(+), 40 deletions(-) create mode 100644 packages/db/prisma/migrations/20260629190000_backfill_sourcebot_mcp_oauth_scope/migration.sql diff --git a/packages/db/prisma/migrations/20260629190000_backfill_sourcebot_mcp_oauth_scope/migration.sql b/packages/db/prisma/migrations/20260629190000_backfill_sourcebot_mcp_oauth_scope/migration.sql new file mode 100644 index 000000000..4925d88b1 --- /dev/null +++ b/packages/db/prisma/migrations/20260629190000_backfill_sourcebot_mcp_oauth_scope/migration.sql @@ -0,0 +1,10 @@ +ALTER TABLE "OAuthToken" ALTER COLUMN "scope" SET DEFAULT 'mcp'; +ALTER TABLE "OAuthRefreshToken" ALTER COLUMN "scope" SET DEFAULT 'mcp'; + +UPDATE "OAuthToken" +SET "scope" = 'mcp' +WHERE "scope" = ''; + +UPDATE "OAuthRefreshToken" +SET "scope" = 'mcp' +WHERE "scope" = ''; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 54444bbe2..7ef0abfcb 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -665,7 +665,7 @@ model OAuthRefreshToken { client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - scope String @default("") + scope String @default("mcp") resource String? // RFC 8707 expiresAt DateTime createdAt DateTime @default(now()) @@ -680,7 +680,7 @@ model OAuthToken { client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - scope String @default("") + scope String @default("mcp") resource String? // RFC 8707: canonical URI of the target resource server expiresAt DateTime createdAt DateTime @default(now()) diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 5e5c28682..8b13c68c2 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -53,7 +53,7 @@ export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[] hash: 'oauthtoken', clientId: 'test-client-id', userId: MOCK_USER_WITH_ACCOUNTS.id, - scope: '', + scope: 'mcp', resource: null, expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now createdAt: new Date(), @@ -65,10 +65,10 @@ export const MOCK_REFRESH_TOKEN: OAuthRefreshToken = { hash: 'refreshtoken', clientId: 'test-client-id', userId: MOCK_USER_WITH_ACCOUNTS.id, - scope: '', + scope: 'mcp', resource: null, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days from now createdAt: new Date(), } -export const userScopedPrismaClientExtension = vi.fn(); \ No newline at end of file +export const userScopedPrismaClientExtension = vi.fn(); diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts index 179d632e1..512dd33e4 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts @@ -1,6 +1,6 @@ import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; import { env } from '@sourcebot/shared'; -import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE, SOURCEBOT_OAUTH_SCOPES } from '@/ee/features/oauth/constants'; import { hasEntitlement } from '@/lib/entitlements'; // RFC 8414: OAuth 2.0 Authorization Server Metadata @@ -24,6 +24,7 @@ export const GET = oauthApiHandler(async () => { revocation_endpoint: `${issuer}/api/ee/oauth/revoke`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], + scopes_supported: SOURCEBOT_OAUTH_SCOPES, code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'], service_documentation: 'https://docs.sourcebot.dev', diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts index 8afdf5031..ac8e2153a 100644 --- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts +++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts @@ -1,7 +1,7 @@ import { env } from '@sourcebot/shared'; import { hasEntitlement } from '@/lib/entitlements'; import { NextRequest } from 'next/server'; -import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; +import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE, SOURCEBOT_OAUTH_SCOPES } from '@/ee/features/oauth/constants'; import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; // RFC 9728: OAuth 2.0 Protected Resource Metadata (path-specific form) @@ -37,5 +37,6 @@ export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { p authorization_servers: [ issuer ], + scopes_supported: SOURCEBOT_OAUTH_SCOPES, }); }); diff --git a/packages/web/src/app/api/(server)/ee/mcp/route.ts b/packages/web/src/app/api/(server)/ee/mcp/route.ts index 15c7f032a..91c4dad67 100644 --- a/packages/web/src/app/api/(server)/ee/mcp/route.ts +++ b/packages/web/src/app/api/(server)/ee/mcp/route.ts @@ -12,6 +12,7 @@ import { sew } from "@/middleware/sew"; import { apiHandler } from '@/lib/apiHandler'; import { env } from '@sourcebot/shared'; import { hasEntitlement } from '@/lib/entitlements'; +import { SOURCEBOT_MCP_OAUTH_SCOPE } from '@/ee/features/oauth/constants'; // On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728) // so they can discover the authorization server and initiate the authorization code flow. @@ -20,16 +21,31 @@ import { hasEntitlement } from '@/lib/entitlements'; // @see: https://datatracker.ietf.org/doc/html/rfc9728 async function mcpErrorResponse(error: ServiceError): Promise { const response = serviceErrorResponse(error); - if (error.statusCode === StatusCodes.UNAUTHORIZED && await hasEntitlement('oauth')) { - const issuer = env.AUTH_URL.replace(/\/$/, ''); - response.headers.set( - 'WWW-Authenticate', - `Bearer realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"` - ); + if ( + (error.statusCode === StatusCodes.UNAUTHORIZED || error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) && + await hasEntitlement('oauth') + ) { + response.headers.set('WWW-Authenticate', mcpBearerChallenge(error)); } return response; } +function mcpBearerChallenge(error: ServiceError): string { + const issuer = env.AUTH_URL.replace(/\/$/, ''); + const params = [ + 'realm="Sourcebot"', + `resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`, + `scope="${SOURCEBOT_MCP_OAUTH_SCOPE}"`, + ]; + + if (error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) { + params.push('error="insufficient_scope"'); + params.push(`error_description="${error.message}"`); + } + + return `Bearer ${params.join(', ')}`; +} + // @see: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management interface McpSession { server: McpServer; @@ -95,7 +111,7 @@ export const POST = apiHandler(async (request: NextRequest) => { await mcpServer.connect(transport); return transport.handleRequest(request); - }) + }, { requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }) ); if (isServiceError(response)) { @@ -139,7 +155,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { } return session.transport.handleRequest(request); - }) + }, { requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }) ); if (isServiceError(result)) { diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts index 3b9843459..08c4aca8d 100644 --- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts +++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts @@ -1,6 +1,5 @@ import { verifyAndExchangeCode, verifyAndRotateRefreshToken } from '@/ee/features/oauth/server'; import { oauthApiHandler } from '@/ee/features/oauth/apiHandler'; -import { env } from '@sourcebot/shared'; import { NextRequest } from 'next/server'; import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants'; import { hasEntitlement } from '@/lib/entitlements'; @@ -61,8 +60,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => { access_token: result.token, refresh_token: result.refreshToken, token_type: 'Bearer', - expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, - scope: '', + expires_in: result.expiresIn, + scope: result.scope, }); } @@ -93,8 +92,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => { access_token: result.token, refresh_token: result.refreshToken, token_type: 'Bearer', - expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, - scope: '', + expires_in: result.expiresIn, + scope: result.scope, }); } diff --git a/packages/web/src/app/oauth/authorize/components/consentScreen.tsx b/packages/web/src/app/oauth/authorize/components/consentScreen.tsx index 94eb27ea8..aaf6dd46f 100644 --- a/packages/web/src/app/oauth/authorize/components/consentScreen.tsx +++ b/packages/web/src/app/oauth/authorize/components/consentScreen.tsx @@ -17,6 +17,7 @@ interface ConsentScreenProps { clientLogoUri: string | null; redirectUri: string; codeChallenge: string; + requestedScope: string | undefined; resource: string | null; state: string | undefined; userEmail: string; @@ -28,6 +29,7 @@ export function ConsentScreen({ clientLogoUri, redirectUri, codeChallenge, + requestedScope, resource, state, userEmail, @@ -43,7 +45,7 @@ export function ConsentScreen({ const onApprove = async () => { captureEvent('wa_oauth_authorization_approved', { clientId, clientName }); setPending('approve'); - const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, state }); + const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, requestedScope, resource, state }); if (!isServiceError(result)) { if (!isPermittedRedirectUrl(result)) { toast({ description: `❌ Redirect URL is not permitted.` }); diff --git a/packages/web/src/app/oauth/authorize/page.tsx b/packages/web/src/app/oauth/authorize/page.tsx index ae58ee585..c69c9c9d8 100644 --- a/packages/web/src/app/oauth/authorize/page.tsx +++ b/packages/web/src/app/oauth/authorize/page.tsx @@ -4,6 +4,7 @@ import { ConsentScreen } from './components/consentScreen'; import { __unsafePrisma } from '@/prisma'; import { hasEntitlement } from '@/lib/entitlements'; import { redirect } from 'next/navigation'; +import { resolveGrantedOAuthScopes } from '@/ee/features/oauth/constants'; export const dynamic = 'force-dynamic'; @@ -15,6 +16,7 @@ interface AuthorizePageProps { code_challenge_method?: string; response_type?: string; state?: string; + scope?: string; resource?: string | string[]; }>; } @@ -25,7 +27,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps } const params = await searchParams; - const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, resource: _resource } = params; + const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, scope, resource: _resource } = params; // RFC 8707 allows multiple resource parameters to indicate a token intended for multiple resources. // 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 return ; } + const grantedScopes = resolveGrantedOAuthScopes(scope); + if ('error' in grantedScopes) { + return ; + } + const client = await __unsafePrisma.oAuthClient.findUnique({ where: { id: client_id } }); if (!client) { @@ -73,6 +80,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps clientLogoUri={client.logoUri} redirectUri={redirect_uri!} codeChallenge={code_challenge!} + requestedScope={scope} resource={resource ?? null} state={state} userEmail={session!.user.email!} diff --git a/packages/web/src/ee/features/oauth/actions.ts b/packages/web/src/ee/features/oauth/actions.ts index 5d9928d63..21127c835 100644 --- a/packages/web/src/ee/features/oauth/actions.ts +++ b/packages/web/src/ee/features/oauth/actions.ts @@ -3,7 +3,10 @@ import { sew } from "@/middleware/sew"; import { generateAndStoreAuthCode } from '@/ee/features/oauth/server'; import { withAuth } from '@/middleware/withAuth'; -import { UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants'; +import { resolveGrantedOAuthScopes, UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants'; +import { ErrorCode } from '@/lib/errorCodes'; +import type { ServiceError } from '@/lib/serviceError'; +import { StatusCodes } from 'http-status-codes'; export interface ConnectedOauthClient { id: string; @@ -37,16 +40,27 @@ export const approveAuthorization = async ({ clientId, redirectUri, codeChallenge, + requestedScope, resource, state, }: { clientId: string; redirectUri: string; codeChallenge: string; + requestedScope: string | undefined; resource: string | null; state: string | undefined; }) => sew(() => withAuth(async ({ user }) => { + const grantedScopes = resolveGrantedOAuthScopes(requestedScope); + if ('error' in grantedScopes) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: grantedScopes.errorDescription, + } satisfies ServiceError; + } + const rawCode = await generateAndStoreAuthCode({ clientId, userId: user.id, diff --git a/packages/web/src/ee/features/oauth/constants.test.ts b/packages/web/src/ee/features/oauth/constants.test.ts index 4918651b9..8799f78cc 100644 --- a/packages/web/src/ee/features/oauth/constants.test.ts +++ b/packages/web/src/ee/features/oauth/constants.test.ts @@ -1,5 +1,45 @@ import { expect, test, describe } from 'vitest'; -import { UNPERMITTED_SCHEMES, isPermittedRedirectUrl } from './constants'; +import { + SOURCEBOT_MCP_OAUTH_SCOPE, + UNPERMITTED_SCHEMES, + hasRequiredOAuthScopes, + isPermittedRedirectUrl, + parseOAuthScopeString, + resolveGrantedOAuthScopes, +} from './constants'; + +describe('OAuth scopes', () => { + test('parses and deduplicates space-delimited scope strings', () => { + expect(parseOAuthScopeString(` ${SOURCEBOT_MCP_OAUTH_SCOPE} extra ${SOURCEBOT_MCP_OAUTH_SCOPE} `)).toEqual([ + SOURCEBOT_MCP_OAUTH_SCOPE, + 'extra', + ]); + }); + + test('defaults authorization requests to the Sourcebot MCP scope', () => { + expect(resolveGrantedOAuthScopes(undefined)).toEqual({ + scopes: [SOURCEBOT_MCP_OAUTH_SCOPE], + }); + }); + + test('accepts the supported Sourcebot MCP scope', () => { + expect(resolveGrantedOAuthScopes(SOURCEBOT_MCP_OAUTH_SCOPE)).toEqual({ + scopes: [SOURCEBOT_MCP_OAUTH_SCOPE], + }); + }); + + test('rejects unsupported scopes', () => { + expect(resolveGrantedOAuthScopes('repo')).toMatchObject({ + error: 'invalid_scope', + errorDescription: 'Unsupported OAuth scope: repo.', + }); + }); + + test('checks required scopes against token scopes', () => { + expect(hasRequiredOAuthScopes([SOURCEBOT_MCP_OAUTH_SCOPE, 'other'], [SOURCEBOT_MCP_OAUTH_SCOPE])).toBe(true); + expect(hasRequiredOAuthScopes(['other'], [SOURCEBOT_MCP_OAUTH_SCOPE])).toBe(false); + }); +}); describe('UNPERMITTED_SCHEMES', () => { // Dangerous schemes that must be blocked diff --git a/packages/web/src/ee/features/oauth/constants.ts b/packages/web/src/ee/features/oauth/constants.ts index 3315d3adf..3bd7ae791 100644 --- a/packages/web/src/ee/features/oauth/constants.ts +++ b/packages/web/src/ee/features/oauth/constants.ts @@ -3,6 +3,58 @@ export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this export const UNPERMITTED_SCHEMES = /^(javascript|data|vbscript):/i; +export const SOURCEBOT_MCP_OAUTH_SCOPE = 'mcp'; +export const DEFAULT_SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const; +export const SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const; + +const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/; + +export function parseOAuthScopeString(scope: string | null | undefined): string[] { + if (!scope) { + return []; + } + + return [...new Set(scope.split(/\s+/).map((token) => token.trim()).filter(Boolean))]; +} + +export function formatOAuthScopeString(scopes: readonly string[]): string { + return scopes.join(' '); +} + +export function hasRequiredOAuthScopes(tokenScopes: readonly string[], requiredScopes: readonly string[]): boolean { + const tokenScopeSet = new Set(tokenScopes); + return requiredScopes.every((scope) => tokenScopeSet.has(scope)); +} + +export function resolveGrantedOAuthScopes(requestedScope: string | null | undefined): { scopes: string[] } | { error: 'invalid_scope'; errorDescription: string } { + const requestedScopes = parseOAuthScopeString(requestedScope); + + for (const scope of requestedScopes) { + if (!OAUTH_SCOPE_TOKEN_REGEX.test(scope)) { + return { + error: 'invalid_scope', + errorDescription: `Invalid OAuth scope token: ${scope}.`, + }; + } + } + + const supportedScopeSet = new Set([...SOURCEBOT_OAUTH_SCOPES]); + for (const scope of requestedScopes) { + if (!supportedScopeSet.has(scope)) { + return { + error: 'invalid_scope', + errorDescription: `Unsupported OAuth scope: ${scope}.`, + }; + } + } + + return { + scopes: requestedScopes.length > 0 + ? requestedScopes + : [...DEFAULT_SOURCEBOT_OAUTH_SCOPES], + }; +} + /** * Returns true if the URL is permitted for use as a redirect target. * Allows relative paths starting with /oauth/complete and http(s) URLs. @@ -19,4 +71,4 @@ export function isPermittedRedirectUrl(url: string): boolean { } catch { return false; } -} \ No newline at end of file +} diff --git a/packages/web/src/ee/features/oauth/server.test.ts b/packages/web/src/ee/features/oauth/server.test.ts index 244749d6e..3e724564b 100644 --- a/packages/web/src/ee/features/oauth/server.test.ts +++ b/packages/web/src/ee/features/oauth/server.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi, beforeEach, describe } from 'vitest'; import { MOCK_REFRESH_TOKEN, MOCK_USER_WITH_ACCOUNTS, prisma } from '@/__mocks__/prisma'; import { verifyAndExchangeCode, verifyAndRotateRefreshToken, revokeToken } from './server'; +import { SOURCEBOT_MCP_OAUTH_SCOPE } from './constants'; vi.mock('@/prisma', async () => { const actual = await vi.importActual('@/__mocks__/prisma'); @@ -65,6 +66,13 @@ describe('verifyAndExchangeCode', () => { token: 'sboa_newtoken', refreshToken: 'sbor_newrefresh', expiresIn: expect.any(Number), + scope: SOURCEBOT_MCP_OAUTH_SCOPE, + }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), }); }); @@ -214,6 +222,37 @@ describe('verifyAndRotateRefreshToken', () => { token: 'sboa_newtoken', refreshToken: 'sbor_newrefresh', expiresIn: expect.any(Number), + scope: SOURCEBOT_MCP_OAUTH_SCOPE, + }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + }); + }); + + test('defaults legacy empty-scope refresh tokens to the MCP scope during rotation', async () => { + prisma.oAuthRefreshToken.findUnique.mockResolvedValue({ + ...MOCK_REFRESH_TOKEN, + scope: '', + }); + prisma.oAuthRefreshToken.delete.mockResolvedValue(MOCK_REFRESH_TOKEN); + prisma.oAuthToken.create.mockResolvedValue({} as never); + prisma.oAuthRefreshToken.create.mockResolvedValue({} as never); + + const result = await verifyAndRotateRefreshToken({ + rawRefreshToken: 'sbor_refreshtoken', + clientId: 'test-client-id', + resource: null, + }); + + expect(result).toMatchObject({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), }); }); diff --git a/packages/web/src/ee/features/oauth/server.ts b/packages/web/src/ee/features/oauth/server.ts index ac5676b23..dadb3852d 100644 --- a/packages/web/src/ee/features/oauth/server.ts +++ b/packages/web/src/ee/features/oauth/server.ts @@ -11,6 +11,9 @@ import { OAUTH_REFRESH_TOKEN_PREFIX, } from '@sourcebot/shared'; import crypto from 'crypto'; +import { DEFAULT_SOURCEBOT_OAUTH_SCOPES, formatOAuthScopeString } from './constants'; + +const DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING = formatOAuthScopeString(DEFAULT_SOURCEBOT_OAUTH_SCOPES); // Generates a random authorization code, hashes it, and stores it alongside the // PKCE code challenge. Returns the raw code to be sent to the client. @@ -59,7 +62,7 @@ export async function verifyAndExchangeCode({ redirectUri: string; codeVerifier: string; resource: string | null; -}): Promise<{ token: string; refreshToken: string; expiresIn: number } | { error: string; errorDescription: string }> { +}): Promise<{ token: string; refreshToken: string; expiresIn: number; scope: string } | { error: string; errorDescription: string }> { const codeHash = hashSecret(rawCode); const authCode = await __unsafePrisma.oAuthAuthorizationCode.findUnique({ @@ -118,6 +121,7 @@ export async function verifyAndExchangeCode({ hash, clientId, userId: authCode.userId, + scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING, resource: authCode.resource, expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), }, @@ -127,13 +131,14 @@ export async function verifyAndExchangeCode({ hash: refreshHash, clientId, userId: authCode.userId, + scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING, resource: authCode.resource, expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), }, }), ]); - return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS }; + return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING }; } // Verifies a refresh token, rotates it, and issues a new access token + refresh token. @@ -147,7 +152,7 @@ export async function verifyAndRotateRefreshToken({ rawRefreshToken: string; clientId: string; resource: string | null; -}): Promise<{ token: string; refreshToken: string; expiresIn: number } | { error: string; errorDescription: string }> { +}): Promise<{ token: string; refreshToken: string; expiresIn: number; scope: string } | { error: string; errorDescription: string }> { if (!rawRefreshToken.startsWith(OAUTH_REFRESH_TOKEN_PREFIX)) { return { error: 'invalid_grant', errorDescription: 'Refresh token is invalid.' }; } @@ -175,6 +180,7 @@ export async function verifyAndRotateRefreshToken({ const { token, hash: newTokenHash } = generateOAuthToken(); const { token: refreshToken, hash: newRefreshHash } = generateOAuthRefreshToken(); + const scope = existing.scope || DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING; await __unsafePrisma.$transaction([ __unsafePrisma.oAuthRefreshToken.delete({ where: { hash } }), @@ -183,6 +189,7 @@ export async function verifyAndRotateRefreshToken({ hash: newTokenHash, clientId, userId: existing.userId, + scope, resource: existing.resource, expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), }, @@ -192,13 +199,14 @@ export async function verifyAndRotateRefreshToken({ hash: newRefreshHash, clientId, userId: existing.userId, + scope, resource: existing.resource, expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), }, }), ]); - return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS }; + return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope }; } // Revokes an access token or refresh token by hashing it and deleting the DB record. diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 2cea6d4ac..0077daf97 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -35,6 +35,7 @@ export enum ErrorCode { LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED', LAST_OWNER_CANNOT_BE_REMOVED = 'LAST_OWNER_CANNOT_BE_REMOVED', API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED', + OAUTH_INSUFFICIENT_SCOPE = 'OAUTH_INSUFFICIENT_SCOPE', MCP_SERVER_ALREADY_EXISTS = 'MCP_SERVER_ALREADY_EXISTS', MCP_SERVER_NOT_FOUND = 'MCP_SERVER_NOT_FOUND', LIGHTHOUSE_UNREACHABLE = 'LIGHTHOUSE_UNREACHABLE', diff --git a/packages/web/src/lib/serviceError.ts b/packages/web/src/lib/serviceError.ts index 98656bc68..f2b447ced 100644 --- a/packages/web/src/lib/serviceError.ts +++ b/packages/web/src/lib/serviceError.ts @@ -87,6 +87,14 @@ export const notAuthenticated = (): ServiceError => { } } +export const insufficientOAuthScope = (requiredScopes: readonly string[]): ServiceError => { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.OAUTH_INSUFFICIENT_SCOPE, + message: `OAuth access token is missing required scope${requiredScopes.length === 1 ? '' : 's'}: ${requiredScopes.join(' ')}`, + }; +} + export const notFound = (message?: string): ServiceError => { return { statusCode: StatusCodes.NOT_FOUND, @@ -126,4 +134,3 @@ export const unresolvedGitRef = (ref: string): ServiceError => { message: `Git reference "${ref}" could not be resolved.`, }; } - diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index bc0586615..1ab1c72e4 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -7,6 +7,7 @@ import { OrgRole } from '@sourcebot/db'; import { ErrorCode } from '../lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { userScopedPrismaClientExtension } from '@/prisma'; +import { SOURCEBOT_MCP_OAUTH_SCOPE } from '@/ee/features/oauth/constants'; const mocks = vi.hoisted(() => { return { @@ -201,6 +202,17 @@ describe('getAuthenticatedUser', () => { expect(result?.source).toBe('oauth'); }); + test('should return parsed scopes for a valid OAuth Bearer token', async () => { + mocks.hasEntitlement.mockReturnValue(true); + prisma.oAuthToken.findUnique.mockResolvedValue({ + ...MOCK_OAUTH_TOKEN, + scope: `${SOURCEBOT_MCP_OAUTH_SCOPE} other ${SOURCEBOT_MCP_OAUTH_SCOPE}`, + }); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); + const result = await getAuthenticatedUser(); + expect(result?.oauthScopes).toEqual([SOURCEBOT_MCP_OAUTH_SCOPE, 'other']); + }); + test('should update lastUsedAt when an OAuth Bearer token is used', async () => { mocks.hasEntitlement.mockReturnValue(true); prisma.oAuthToken.findUnique.mockResolvedValue(MOCK_OAUTH_TOKEN); @@ -473,6 +485,82 @@ describe('getAuthContext', () => { }); }); }); + + describe('requiredOAuthScopes', () => { + test('should allow OAuth bearer tokens that contain the required scope', async () => { + const userId = 'test-user-id'; + mocks.hasEntitlement.mockReturnValue(true); + prisma.oAuthToken.findUnique.mockResolvedValue({ + ...MOCK_OAUTH_TOKEN, + user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, + scope: SOURCEBOT_MCP_OAUTH_SCOPE, + }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.MEMBER, + }); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); + + const authContext = await getAuthContext({ requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }); + + expect(authContext).toMatchObject({ + user: { id: userId }, + org: MOCK_ORG, + role: OrgRole.MEMBER, + }); + }); + + test('should return a 403 service error when an OAuth bearer token is missing the required scope', async () => { + const userId = 'test-user-id'; + mocks.hasEntitlement.mockReturnValue(true); + prisma.oAuthToken.findUnique.mockResolvedValue({ + ...MOCK_OAUTH_TOKEN, + user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, + scope: 'other', + }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.MEMBER, + }); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); + + const authContext = await getAuthContext({ requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }); + + expect(authContext).toStrictEqual({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.OAUTH_INSUFFICIENT_SCOPE, + message: `OAuth access token is missing required scope: ${SOURCEBOT_MCP_OAUTH_SCOPE}`, + }); + }); + + test('should not apply OAuth scope requirements to API keys', async () => { + const userId = 'test-user-id'; + prisma.user.findUnique.mockResolvedValue({ ...MOCK_USER_WITH_ACCOUNTS, id: userId }); + prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); + prisma.userToOrg.findUnique.mockResolvedValue({ + joinedAt: new Date(), + userId, + orgId: MOCK_ORG.id, + role: OrgRole.MEMBER, + }); + prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); + setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); + + const authContext = await getAuthContext({ requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }); + + expect(authContext).toMatchObject({ + user: { id: userId }, + org: MOCK_ORG, + role: OrgRole.MEMBER, + }); + }); + }); }); describe('withAuth', () => { diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 0e930fa63..bd9db9524 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -3,12 +3,13 @@ import { hashSecret, OAUTH_ACCESS_TOKEN_PREFIX, API_KEY_PREFIX, LEGACY_API_KEY_P import { ApiKey, Org, OrgRole, PrismaClient, UserWithAccounts } from "@sourcebot/db"; import { headers } from "next/headers"; import { auth } from "../auth"; -import { notAuthenticated, notFound, ServiceError } from "../lib/serviceError"; +import { insufficientOAuthScope, notAuthenticated, notFound, ServiceError } from "../lib/serviceError"; import { SINGLE_TENANT_ORG_ID } from "../lib/constants"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "../lib/errorCodes"; import { isServiceError } from "../lib/utils"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; +import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/constants"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -28,9 +29,12 @@ type OptionalAuthContext = prisma: PrismaClient; }; +type AuthOptions = { + requiredOAuthScopes?: readonly string[]; +}; -export const withAuth = async (fn: (params: RequiredAuthContext) => Promise) => { - const authContext = await getAuthContext(); +export const withAuth = async (fn: (params: RequiredAuthContext) => Promise, options: AuthOptions = {}) => { + const authContext = await getAuthContext(options); if (isServiceError(authContext)) { return authContext; @@ -45,8 +49,8 @@ export const withAuth = async (fn: (params: RequiredAuthContext) => Promise(fn: (params: OptionalAuthContext) => Promise) => { - const authContext = await getAuthContext(); +export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => Promise, options: AuthOptions = {}) => { + const authContext = await getAuthContext(options); if (isServiceError(authContext)) { return authContext; } @@ -61,7 +65,7 @@ export const withOptionalAuth = async (fn: (params: OptionalAuthContext) => P return fn(authContext); }; -export const getAuthContext = async (): Promise => { +export const getAuthContext = async (options: AuthOptions = {}): Promise => { const authResult = await getAuthenticatedUser(); const org = await __unsafePrisma.org.findUnique({ @@ -99,6 +103,14 @@ export const getAuthContext = async (): Promise { type AuthSource = 'session' | 'oauth' | 'api_key'; -export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, source: AuthSource } | undefined> => { +export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, source: AuthSource, oauthScopes?: string[] } | undefined> => { // First, check if we have a valid JWT session. const session = await auth(); if (session) { @@ -170,7 +182,7 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, where: { hash }, data: { lastUsedAt: new Date() }, }); - return { user: oauthToken.user, source: 'oauth' }; + return { user: oauthToken.user, source: 'oauth', oauthScopes: parseOAuthScopeString(oauthToken.scope) }; } } @@ -263,5 +275,3 @@ export const getVerifiedApiObject = async (apiKeyString: string): Promise Date: Mon, 29 Jun 2026 13:49:23 -0700 Subject: [PATCH 2/7] Add changelog entry for OAuth scope validation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676584906..3beb84d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [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) - Verified GitHub review webhook deliveries before processing them. [#1378](https://github.com/sourcebot-dev/sourcebot/pull/1378) - Passed Zoekt index parameters via argv to preserve revision names with punctuation. [#1376](https://github.com/sourcebot-dev/sourcebot/pull/1376) +- [EE] Validated OAuth bearer token scopes before allowing access to the Sourcebot MCP resource server. [#1396](https://github.com/sourcebot-dev/sourcebot/pull/1396) ## [5.0.4] - 2026-06-18 From 6e14b501886b8f00e2f6bad2c803f52c242c2a22 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 29 Jun 2026 17:11:48 -0700 Subject: [PATCH 3/7] update migration --- .../migration.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/db/prisma/migrations/{20260629190000_backfill_sourcebot_mcp_oauth_scope => 20260629194000_backfill_sourcebot_mcp_oauth_scope}/migration.sql (100%) diff --git a/packages/db/prisma/migrations/20260629190000_backfill_sourcebot_mcp_oauth_scope/migration.sql b/packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql similarity index 100% rename from packages/db/prisma/migrations/20260629190000_backfill_sourcebot_mcp_oauth_scope/migration.sql rename to packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql From 70bf92feb70d03155afe19dcbc3fc673e9e10c39 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 29 Jun 2026 17:52:41 -0700 Subject: [PATCH 4/7] wip --- .../migration.sql | 1 + .../migration.sql | 10 --- packages/db/prisma/schema.prisma | 5 +- .../authorize/components/consentScreen.tsx | 2 +- packages/web/src/app/oauth/authorize/page.tsx | 2 +- packages/web/src/ee/features/oauth/actions.ts | 4 +- .../web/src/ee/features/oauth/constants.ts | 72 ++----------------- .../web/src/ee/features/oauth/server.test.ts | 63 +++++++++++++++- packages/web/src/ee/features/oauth/server.ts | 13 ++-- .../{constants.test.ts => utils.test.ts} | 4 +- packages/web/src/ee/features/oauth/utils.ts | 70 ++++++++++++++++++ packages/web/src/middleware/withAuth.ts | 5 +- 12 files changed, 161 insertions(+), 90 deletions(-) create mode 100644 packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql delete mode 100644 packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql rename packages/web/src/ee/features/oauth/{constants.test.ts => utils.test.ts} (99%) create mode 100644 packages/web/src/ee/features/oauth/utils.ts diff --git a/packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql b/packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql new file mode 100644 index 000000000..701dfe562 --- /dev/null +++ b/packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "scope" TEXT NOT NULL DEFAULT ''; diff --git a/packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql b/packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql deleted file mode 100644 index 4925d88b1..000000000 --- a/packages/db/prisma/migrations/20260629194000_backfill_sourcebot_mcp_oauth_scope/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ -ALTER TABLE "OAuthToken" ALTER COLUMN "scope" SET DEFAULT 'mcp'; -ALTER TABLE "OAuthRefreshToken" ALTER COLUMN "scope" SET DEFAULT 'mcp'; - -UPDATE "OAuthToken" -SET "scope" = 'mcp' -WHERE "scope" = ''; - -UPDATE "OAuthRefreshToken" -SET "scope" = 'mcp' -WHERE "scope" = ''; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 28ad5bf73..8e3ef035c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -653,6 +653,7 @@ model OAuthAuthorizationCode { user User @relation(fields: [userId], references: [id], onDelete: Cascade) redirectUri String codeChallenge String // BASE64URL(SHA-256(codeVerifier)) + scope String @default("") resource String? // RFC 8707: canonical URI of the target resource server dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding expiresAt DateTime @@ -666,7 +667,7 @@ model OAuthRefreshToken { client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - scope String @default("mcp") + scope String @default("") resource String? // RFC 8707 dpopJkt String? // RFC 9449 expiresAt DateTime @@ -682,7 +683,7 @@ model OAuthToken { client OAuthClient @relation(fields: [clientId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - scope String @default("mcp") + scope String @default("") resource String? // RFC 8707: canonical URI of the target resource server dpopJkt String? // RFC 9449: DPoP JWK SHA-256 thumbprint binding expiresAt DateTime diff --git a/packages/web/src/app/oauth/authorize/components/consentScreen.tsx b/packages/web/src/app/oauth/authorize/components/consentScreen.tsx index 9bcdd8a6c..2d17da5b0 100644 --- a/packages/web/src/app/oauth/authorize/components/consentScreen.tsx +++ b/packages/web/src/app/oauth/authorize/components/consentScreen.tsx @@ -1,7 +1,7 @@ 'use client'; import { approveAuthorization, denyAuthorization } from '@/ee/features/oauth/actions'; -import { isPermittedRedirectUrl } from '@/ee/features/oauth/constants'; +import { isPermittedRedirectUrl } from '@/ee/features/oauth/utils'; import { LoadingButton } from '@/components/ui/loading-button'; import { isServiceError } from '@/lib/utils'; import { ClientIcon } from './clientIcon'; diff --git a/packages/web/src/app/oauth/authorize/page.tsx b/packages/web/src/app/oauth/authorize/page.tsx index 0fa2b9e97..210b6b0fe 100644 --- a/packages/web/src/app/oauth/authorize/page.tsx +++ b/packages/web/src/app/oauth/authorize/page.tsx @@ -4,7 +4,7 @@ import { ConsentScreen } from './components/consentScreen'; import { __unsafePrisma } from '@/prisma'; import { hasEntitlement } from '@/lib/entitlements'; import { redirect } from 'next/navigation'; -import { resolveGrantedOAuthScopes } from '@/ee/features/oauth/constants'; +import { resolveGrantedOAuthScopes } from '@/ee/features/oauth/utils'; import { isValidDpopJkt } from '@/ee/features/oauth/dpop'; export const dynamic = 'force-dynamic'; diff --git a/packages/web/src/ee/features/oauth/actions.ts b/packages/web/src/ee/features/oauth/actions.ts index c9433eee9..c92421179 100644 --- a/packages/web/src/ee/features/oauth/actions.ts +++ b/packages/web/src/ee/features/oauth/actions.ts @@ -3,7 +3,8 @@ import { sew } from "@/middleware/sew"; import { generateAndStoreAuthCode } from '@/ee/features/oauth/server'; import { withAuth } from '@/middleware/withAuth'; -import { resolveGrantedOAuthScopes, UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants'; +import { UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants'; +import { formatOAuthScopeString, resolveGrantedOAuthScopes } from '@/ee/features/oauth/utils'; import { isValidDpopJkt } from '@/ee/features/oauth/dpop'; import { ErrorCode } from '@/lib/errorCodes'; import type { ServiceError } from '@/lib/serviceError'; @@ -77,6 +78,7 @@ export const approveAuthorization = async ({ userId: user.id, redirectUri, codeChallenge, + scope: formatOAuthScopeString(grantedScopes.scopes), resource, dpopJkt, }); diff --git a/packages/web/src/ee/features/oauth/constants.ts b/packages/web/src/ee/features/oauth/constants.ts index 3bd7ae791..b01d3ff5f 100644 --- a/packages/web/src/ee/features/oauth/constants.ts +++ b/packages/web/src/ee/features/oauth/constants.ts @@ -3,72 +3,10 @@ export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this export const UNPERMITTED_SCHEMES = /^(javascript|data|vbscript):/i; +export const SOURCEBOT_OAUTH_SCOPES = [ + "mcp" +]; +export type SourcebotOauthScope = (typeof SOURCEBOT_OAUTH_SCOPES)[number]; + export const SOURCEBOT_MCP_OAUTH_SCOPE = 'mcp'; export const DEFAULT_SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const; -export const SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const; - -const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/; - -export function parseOAuthScopeString(scope: string | null | undefined): string[] { - if (!scope) { - return []; - } - - return [...new Set(scope.split(/\s+/).map((token) => token.trim()).filter(Boolean))]; -} - -export function formatOAuthScopeString(scopes: readonly string[]): string { - return scopes.join(' '); -} - -export function hasRequiredOAuthScopes(tokenScopes: readonly string[], requiredScopes: readonly string[]): boolean { - const tokenScopeSet = new Set(tokenScopes); - return requiredScopes.every((scope) => tokenScopeSet.has(scope)); -} - -export function resolveGrantedOAuthScopes(requestedScope: string | null | undefined): { scopes: string[] } | { error: 'invalid_scope'; errorDescription: string } { - const requestedScopes = parseOAuthScopeString(requestedScope); - - for (const scope of requestedScopes) { - if (!OAUTH_SCOPE_TOKEN_REGEX.test(scope)) { - return { - error: 'invalid_scope', - errorDescription: `Invalid OAuth scope token: ${scope}.`, - }; - } - } - - const supportedScopeSet = new Set([...SOURCEBOT_OAUTH_SCOPES]); - for (const scope of requestedScopes) { - if (!supportedScopeSet.has(scope)) { - return { - error: 'invalid_scope', - errorDescription: `Unsupported OAuth scope: ${scope}.`, - }; - } - } - - return { - scopes: requestedScopes.length > 0 - ? requestedScopes - : [...DEFAULT_SOURCEBOT_OAUTH_SCOPES], - }; -} - -/** - * Returns true if the URL is permitted for use as a redirect target. - * Allows relative paths starting with /oauth/complete and http(s) URLs. - * Returns false for dangerous schemes like javascript:, data:, vbscript:. - */ -export function isPermittedRedirectUrl(url: string): boolean { - if (url.startsWith('/oauth/complete')) { - return true; - } - - try { - const parsed = new URL(url); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; - } catch { - return false; - } -} diff --git a/packages/web/src/ee/features/oauth/server.test.ts b/packages/web/src/ee/features/oauth/server.test.ts index 8a947e818..6fee0d642 100644 --- a/packages/web/src/ee/features/oauth/server.test.ts +++ b/packages/web/src/ee/features/oauth/server.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi, beforeEach, describe } from 'vitest'; import { MOCK_REFRESH_TOKEN, MOCK_USER_WITH_ACCOUNTS, prisma } from '@/__mocks__/prisma'; -import { verifyAndExchangeCode, verifyAndRotateRefreshToken, revokeToken } from './server'; +import { generateAndStoreAuthCode, verifyAndExchangeCode, verifyAndRotateRefreshToken, revokeToken } from './server'; import { SOURCEBOT_MCP_OAUTH_SCOPE } from './constants'; vi.mock('@/prisma', async () => { @@ -31,6 +31,7 @@ const VALID_AUTH_CODE = { redirectUri: 'http://localhost:9999/callback', // SHA-256('myverifier') base64url codeChallenge: 'Eb223qLjTQNFkRjCVsrDbsBk5ycPKwHdbHNRX99tTeQ', + scope: SOURCEBOT_MCP_OAUTH_SCOPE, resource: null, dpopJkt: null, expiresAt: new Date(Date.now() + 10 * 60 * 1000), @@ -44,6 +45,39 @@ beforeEach(() => { prisma.$transaction.mockResolvedValue([] as any); }); +// --------------------------------------------------------------------------- +// generateAndStoreAuthCode +// --------------------------------------------------------------------------- + +describe('generateAndStoreAuthCode', () => { + test('stores the granted scope with the authorization code', async () => { + prisma.oAuthAuthorizationCode.create.mockResolvedValue(VALID_AUTH_CODE); + + const code = await generateAndStoreAuthCode({ + clientId: 'test-client-id', + userId: MOCK_USER_WITH_ACCOUNTS.id, + redirectUri: 'http://localhost:9999/callback', + codeChallenge: 'challenge', + scope: 'mcp other', + resource: 'https://sourcebot.test/api/mcp', + dpopJkt: 'dpop-thumbprint', + }); + + expect(code).toEqual(expect.any(String)); + expect(prisma.oAuthAuthorizationCode.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + clientId: 'test-client-id', + userId: MOCK_USER_WITH_ACCOUNTS.id, + redirectUri: 'http://localhost:9999/callback', + codeChallenge: 'challenge', + scope: 'mcp other', + resource: 'https://sourcebot.test/api/mcp', + dpopJkt: 'dpop-thumbprint', + }), + }); + }); +}); + // --------------------------------------------------------------------------- // verifyAndExchangeCode // --------------------------------------------------------------------------- @@ -78,6 +112,33 @@ describe('verifyAndExchangeCode', () => { }); }); + test('uses the scope bound to the authorization code when issuing tokens', async () => { + prisma.oAuthAuthorizationCode.findUnique.mockResolvedValue({ + ...VALID_AUTH_CODE, + scope: 'mcp other', + }); + prisma.oAuthAuthorizationCode.delete.mockResolvedValue(VALID_AUTH_CODE); + prisma.oAuthToken.create.mockResolvedValue({} as never); + prisma.oAuthRefreshToken.create.mockResolvedValue({} as never); + + const result = await verifyAndExchangeCode({ + rawCode: VALID_CODE_HASH, + clientId: 'test-client-id', + redirectUri: 'http://localhost:9999/callback', + codeVerifier: 'myverifier', + resource: null, + dpopJkt: null, + }); + + expect(result).toMatchObject({ scope: 'mcp other' }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: 'mcp other' }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: 'mcp other' }), + }); + }); + test('returns invalid_grant if code is not found', async () => { prisma.oAuthAuthorizationCode.findUnique.mockResolvedValue(null); diff --git a/packages/web/src/ee/features/oauth/server.ts b/packages/web/src/ee/features/oauth/server.ts index cf74378b2..3c5c72094 100644 --- a/packages/web/src/ee/features/oauth/server.ts +++ b/packages/web/src/ee/features/oauth/server.ts @@ -11,7 +11,8 @@ import { OAUTH_REFRESH_TOKEN_PREFIX, } from '@sourcebot/shared'; import crypto from 'crypto'; -import { DEFAULT_SOURCEBOT_OAUTH_SCOPES, formatOAuthScopeString } from './constants'; +import { DEFAULT_SOURCEBOT_OAUTH_SCOPES } from './constants'; +import { formatOAuthScopeString } from './utils'; const DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING = formatOAuthScopeString(DEFAULT_SOURCEBOT_OAUTH_SCOPES); @@ -22,6 +23,7 @@ export async function generateAndStoreAuthCode({ userId, redirectUri, codeChallenge, + scope, resource, dpopJkt, }: { @@ -29,6 +31,7 @@ export async function generateAndStoreAuthCode({ userId: string; redirectUri: string; codeChallenge: string; + scope: string; resource: string | null; dpopJkt: string | null; }): Promise { @@ -42,6 +45,7 @@ export async function generateAndStoreAuthCode({ userId, redirectUri, codeChallenge, + scope, resource, dpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_AUTHORIZATION_CODE_TTL_SECONDS * 1000), @@ -123,6 +127,7 @@ export async function verifyAndExchangeCode({ const { token, hash } = generateOAuthToken(); const { token: refreshToken, hash: refreshHash } = generateOAuthRefreshToken(); + const scope = authCode.scope; const tokenDpopJkt = authCode.dpopJkt ?? dpopJkt; await __unsafePrisma.$transaction([ @@ -131,7 +136,7 @@ export async function verifyAndExchangeCode({ hash, clientId, userId: authCode.userId, - scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING, + scope, resource: authCode.resource, dpopJkt: tokenDpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), @@ -142,7 +147,7 @@ export async function verifyAndExchangeCode({ hash: refreshHash, clientId, userId: authCode.userId, - scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING, + scope, resource: authCode.resource, dpopJkt: tokenDpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), @@ -150,7 +155,7 @@ export async function verifyAndExchangeCode({ }), ]); - return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope: DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING, dpopJkt: tokenDpopJkt }; + return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope, dpopJkt: tokenDpopJkt }; } // Verifies a refresh token, rotates it, and issues a new access token + refresh token. diff --git a/packages/web/src/ee/features/oauth/constants.test.ts b/packages/web/src/ee/features/oauth/utils.test.ts similarity index 99% rename from packages/web/src/ee/features/oauth/constants.test.ts rename to packages/web/src/ee/features/oauth/utils.test.ts index 8799f78cc..cc0df7575 100644 --- a/packages/web/src/ee/features/oauth/constants.test.ts +++ b/packages/web/src/ee/features/oauth/utils.test.ts @@ -2,11 +2,13 @@ import { expect, test, describe } from 'vitest'; import { SOURCEBOT_MCP_OAUTH_SCOPE, UNPERMITTED_SCHEMES, +} from './constants'; +import { hasRequiredOAuthScopes, isPermittedRedirectUrl, parseOAuthScopeString, resolveGrantedOAuthScopes, -} from './constants'; +} from './utils'; describe('OAuth scopes', () => { test('parses and deduplicates space-delimited scope strings', () => { diff --git a/packages/web/src/ee/features/oauth/utils.ts b/packages/web/src/ee/features/oauth/utils.ts new file mode 100644 index 000000000..d3878ad28 --- /dev/null +++ b/packages/web/src/ee/features/oauth/utils.ts @@ -0,0 +1,70 @@ +import { + DEFAULT_SOURCEBOT_OAUTH_SCOPES, + SOURCEBOT_OAUTH_SCOPES, +} from './constants'; + +const OAUTH_SCOPE_TOKEN_REGEX = /^[\x21\x23-\x5B\x5D-\x7E]+$/; + +export function parseOAuthScopeString(scope: string | null | undefined): string[] { + if (!scope) { + return []; + } + + return [...new Set(scope.split(/\s+/).map((token) => token.trim()).filter(Boolean))]; +} + +export function formatOAuthScopeString(scopes: readonly string[]): string { + return scopes.join(' '); +} + +export function hasRequiredOAuthScopes(tokenScopes: readonly string[], requiredScopes: readonly string[]): boolean { + const tokenScopeSet = new Set(tokenScopes); + return requiredScopes.every((scope) => tokenScopeSet.has(scope)); +} + +export function resolveGrantedOAuthScopes(requestedScope: string | null | undefined): { scopes: string[] } | { error: 'invalid_scope'; errorDescription: string } { + const requestedScopes = parseOAuthScopeString(requestedScope); + + for (const scope of requestedScopes) { + if (!OAUTH_SCOPE_TOKEN_REGEX.test(scope)) { + return { + error: 'invalid_scope', + errorDescription: `Invalid OAuth scope token: ${scope}.`, + }; + } + } + + const supportedScopeSet = new Set([...SOURCEBOT_OAUTH_SCOPES]); + for (const scope of requestedScopes) { + if (!supportedScopeSet.has(scope)) { + return { + error: 'invalid_scope', + errorDescription: `Unsupported OAuth scope: ${scope}.`, + }; + } + } + + return { + scopes: requestedScopes.length > 0 + ? requestedScopes + : [...DEFAULT_SOURCEBOT_OAUTH_SCOPES], + }; +} + +/** + * Returns true if the URL is permitted for use as a redirect target. + * Allows relative paths starting with /oauth/complete and http(s) URLs. + * Returns false for dangerous schemes like javascript:, data:, vbscript:. + */ +export function isPermittedRedirectUrl(url: string): boolean { + if (url.startsWith('/oauth/complete')) { + return true; + } + + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 8058daa0a..84c24e897 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -9,9 +9,10 @@ import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "../lib/errorCodes"; import { isServiceError } from "../lib/utils"; import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; -import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/constants"; +import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; +import { SourcebotOauthScope } from "@/ee/features/oauth/constants"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -32,7 +33,7 @@ type OptionalAuthContext = }; type AuthOptions = { - requiredOAuthScopes?: readonly string[]; + requiredOAuthScopes?: readonly SourcebotOauthScope[]; }; export const withAuth = async (fn: (params: RequiredAuthContext) => Promise, options: AuthOptions = {}) => { From 17f79b38adf242d25186cdbaae1b1601acfc7d7d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 29 Jun 2026 17:53:49 -0700 Subject: [PATCH 5/7] wip --- .../migration.sql | 1 - .../migration.sql | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql create mode 100644 packages/db/prisma/migrations/20260630005335_add_oauth_scope_to_authorization_code/migration.sql diff --git a/packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql b/packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql deleted file mode 100644 index 701dfe562..000000000 --- a/packages/db/prisma/migrations/20260629194000_add_oauth_scope_to_authorization_code/migration.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "scope" TEXT NOT NULL DEFAULT ''; diff --git a/packages/db/prisma/migrations/20260630005335_add_oauth_scope_to_authorization_code/migration.sql b/packages/db/prisma/migrations/20260630005335_add_oauth_scope_to_authorization_code/migration.sql new file mode 100644 index 000000000..5123ec92f --- /dev/null +++ b/packages/db/prisma/migrations/20260630005335_add_oauth_scope_to_authorization_code/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OAuthAuthorizationCode" ADD COLUMN "scope" TEXT NOT NULL DEFAULT ''; From 090a2ae8942ddf5eb23411bb8e9b213c197b30c8 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 30 Jun 2026 10:58:44 -0700 Subject: [PATCH 6/7] wip --- packages/web/src/app/api/(server)/ee/mcp/route.ts | 4 ++-- packages/web/src/ee/features/oauth/constants.ts | 4 ++-- packages/web/src/ee/features/oauth/server.ts | 6 +----- packages/web/src/middleware/withAuth.ts | 4 ++-- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/web/src/app/api/(server)/ee/mcp/route.ts b/packages/web/src/app/api/(server)/ee/mcp/route.ts index 01a4c5b3d..35172c0f3 100644 --- a/packages/web/src/app/api/(server)/ee/mcp/route.ts +++ b/packages/web/src/app/api/(server)/ee/mcp/route.ts @@ -112,7 +112,7 @@ export const POST = apiHandler(async (request: NextRequest) => { await mcpServer.connect(transport); return transport.handleRequest(request); - }, { requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }) + }, { requiredOAuthScopes: ['mcp'] }) ); if (isServiceError(response)) { @@ -156,7 +156,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { } return session.transport.handleRequest(request); - }, { requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }) + }, { requiredOAuthScopes: ['mcp'] }) ); if (isServiceError(result)) { diff --git a/packages/web/src/ee/features/oauth/constants.ts b/packages/web/src/ee/features/oauth/constants.ts index b01d3ff5f..6e265eb31 100644 --- a/packages/web/src/ee/features/oauth/constants.ts +++ b/packages/web/src/ee/features/oauth/constants.ts @@ -5,8 +5,8 @@ export const UNPERMITTED_SCHEMES = /^(javascript|data|vbscript):/i; export const SOURCEBOT_OAUTH_SCOPES = [ "mcp" -]; -export type SourcebotOauthScope = (typeof SOURCEBOT_OAUTH_SCOPES)[number]; +] as const; +export type SourcebotOAuthScope = (typeof SOURCEBOT_OAUTH_SCOPES)[number]; export const SOURCEBOT_MCP_OAUTH_SCOPE = 'mcp'; export const DEFAULT_SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const; diff --git a/packages/web/src/ee/features/oauth/server.ts b/packages/web/src/ee/features/oauth/server.ts index 3c5c72094..9253ce93a 100644 --- a/packages/web/src/ee/features/oauth/server.ts +++ b/packages/web/src/ee/features/oauth/server.ts @@ -11,10 +11,6 @@ import { OAUTH_REFRESH_TOKEN_PREFIX, } from '@sourcebot/shared'; import crypto from 'crypto'; -import { DEFAULT_SOURCEBOT_OAUTH_SCOPES } from './constants'; -import { formatOAuthScopeString } from './utils'; - -const DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING = formatOAuthScopeString(DEFAULT_SOURCEBOT_OAUTH_SCOPES); // Generates a random authorization code, hashes it, and stores it alongside the // PKCE code challenge. Returns the raw code to be sent to the client. @@ -203,7 +199,7 @@ export async function verifyAndRotateRefreshToken({ const { token, hash: newTokenHash } = generateOAuthToken(); const { token: refreshToken, hash: newRefreshHash } = generateOAuthRefreshToken(); - const scope = existing.scope || DEFAULT_SOURCEBOT_OAUTH_SCOPE_STRING; + const scope = existing.scope; const tokenDpopJkt = existing.dpopJkt ?? dpopJkt; await __unsafePrisma.$transaction([ diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 84c24e897..4b2e768cc 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -12,7 +12,7 @@ import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; -import { SourcebotOauthScope } from "@/ee/features/oauth/constants"; +import { SourcebotOAuthScope } from "@/ee/features/oauth/constants"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -33,7 +33,7 @@ type OptionalAuthContext = }; type AuthOptions = { - requiredOAuthScopes?: readonly SourcebotOauthScope[]; + requiredOAuthScopes?: readonly SourcebotOAuthScope[]; }; export const withAuth = async (fn: (params: RequiredAuthContext) => Promise, options: AuthOptions = {}) => { From 8ddf6422c4f92ba19971afd0208a9009b26aa531 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 30 Jun 2026 19:38:17 -0700 Subject: [PATCH 7/7] remove oauth scopes --- packages/web/package.json | 3 +- packages/web/src/__mocks__/prisma.ts | 4 +- .../web/src/app/api/(server)/ee/mcp/route.ts | 11 +- .../web/src/ee/features/oauth/constants.ts | 7 +- .../web/src/ee/features/oauth/server.test.ts | 39 +- .../web/src/ee/features/oauth/utils.test.ts | 19 +- packages/web/src/ee/features/oauth/utils.ts | 5 +- packages/web/src/middleware/withAuth.test.ts | 17 +- packages/web/src/middleware/withAuth.ts | 9 +- packages/web/tools/oauthFlow.ts | 496 ++++++++++++++++++ 10 files changed, 553 insertions(+), 57 deletions(-) create mode 100644 packages/web/tools/oauthFlow.ts diff --git a/packages/web/package.json b/packages/web/package.json index 61f1dade0..f3dfe021b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,7 +11,8 @@ "openapi:generate": "tsx tools/generateOpenApi.ts", "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", "dev:emails": "email dev --dir ./src/emails", - "tool:decrypt-jwe": "tsx tools/decryptJWE.ts" + "tool:decrypt-jwe": "tsx tools/decryptJWE.ts", + "tool:oauth-flow": "tsx tools/oauthFlow.ts" }, "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.94", diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 96827eaad..3e46e17bd 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -52,7 +52,7 @@ export const MOCK_OAUTH_TOKEN: OAuthToken & { user: User & { accounts: Account[] hash: 'oauthtoken', clientId: 'test-client-id', userId: MOCK_USER_WITH_ACCOUNTS.id, - scope: 'mcp', + scope: '', resource: null, dpopJkt: null, expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour from now @@ -65,7 +65,7 @@ export const MOCK_REFRESH_TOKEN: OAuthRefreshToken = { hash: 'refreshtoken', clientId: 'test-client-id', userId: MOCK_USER_WITH_ACCOUNTS.id, - scope: 'mcp', + scope: '', resource: null, dpopJkt: null, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days from now diff --git a/packages/web/src/app/api/(server)/ee/mcp/route.ts b/packages/web/src/app/api/(server)/ee/mcp/route.ts index 35172c0f3..b9388ff6a 100644 --- a/packages/web/src/app/api/(server)/ee/mcp/route.ts +++ b/packages/web/src/app/api/(server)/ee/mcp/route.ts @@ -12,7 +12,7 @@ import { sew } from "@/middleware/sew"; import { apiHandler } from '@/lib/apiHandler'; import { env } from '@sourcebot/shared'; import { hasEntitlement } from '@/lib/entitlements'; -import { SOURCEBOT_MCP_OAUTH_SCOPE } from '@/ee/features/oauth/constants'; +import { SOURCEBOT_OAUTH_SCOPES } from '@/ee/features/oauth/constants'; // On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728) // so they can discover the authorization server and initiate the authorization code flow. @@ -36,8 +36,11 @@ function mcpOAuthChallenge(scheme: 'Bearer' | 'DPoP', error: ServiceError): stri const params = [ 'realm="Sourcebot"', `resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`, - `scope="${SOURCEBOT_MCP_OAUTH_SCOPE}"`, ]; + const scope = SOURCEBOT_OAUTH_SCOPES.join(' '); + if (scope) { + params.push(`scope="${scope}"`); + } if (error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) { params.push('error="insufficient_scope"'); @@ -112,7 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => { await mcpServer.connect(transport); return transport.handleRequest(request); - }, { requiredOAuthScopes: ['mcp'] }) + }) ); if (isServiceError(response)) { @@ -156,7 +159,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => { } return session.transport.handleRequest(request); - }, { requiredOAuthScopes: ['mcp'] }) + }) ); if (isServiceError(result)) { diff --git a/packages/web/src/ee/features/oauth/constants.ts b/packages/web/src/ee/features/oauth/constants.ts index 6e265eb31..6f2b0835d 100644 --- a/packages/web/src/ee/features/oauth/constants.ts +++ b/packages/web/src/ee/features/oauth/constants.ts @@ -3,10 +3,5 @@ export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this export const UNPERMITTED_SCHEMES = /^(javascript|data|vbscript):/i; -export const SOURCEBOT_OAUTH_SCOPES = [ - "mcp" -] as const; +export const SOURCEBOT_OAUTH_SCOPES = [] as const; export type SourcebotOAuthScope = (typeof SOURCEBOT_OAUTH_SCOPES)[number]; - -export const SOURCEBOT_MCP_OAUTH_SCOPE = 'mcp'; -export const DEFAULT_SOURCEBOT_OAUTH_SCOPES = [SOURCEBOT_MCP_OAUTH_SCOPE] as const; diff --git a/packages/web/src/ee/features/oauth/server.test.ts b/packages/web/src/ee/features/oauth/server.test.ts index 6fee0d642..2fce8597e 100644 --- a/packages/web/src/ee/features/oauth/server.test.ts +++ b/packages/web/src/ee/features/oauth/server.test.ts @@ -1,7 +1,6 @@ import { expect, test, vi, beforeEach, describe } from 'vitest'; import { MOCK_REFRESH_TOKEN, MOCK_USER_WITH_ACCOUNTS, prisma } from '@/__mocks__/prisma'; import { generateAndStoreAuthCode, verifyAndExchangeCode, verifyAndRotateRefreshToken, revokeToken } from './server'; -import { SOURCEBOT_MCP_OAUTH_SCOPE } from './constants'; vi.mock('@/prisma', async () => { const actual = await vi.importActual('@/__mocks__/prisma'); @@ -24,6 +23,8 @@ vi.mock('@sourcebot/shared', () => ({ })); const VALID_CODE_HASH = 'validcode'; +const EMPTY_OAUTH_SCOPE = ''; +const TEST_OAUTH_SCOPE = 'read other'; const VALID_AUTH_CODE = { codeHash: VALID_CODE_HASH, clientId: 'test-client-id', @@ -31,7 +32,7 @@ const VALID_AUTH_CODE = { redirectUri: 'http://localhost:9999/callback', // SHA-256('myverifier') base64url codeChallenge: 'Eb223qLjTQNFkRjCVsrDbsBk5ycPKwHdbHNRX99tTeQ', - scope: SOURCEBOT_MCP_OAUTH_SCOPE, + scope: EMPTY_OAUTH_SCOPE, resource: null, dpopJkt: null, expiresAt: new Date(Date.now() + 10 * 60 * 1000), @@ -58,7 +59,7 @@ describe('generateAndStoreAuthCode', () => { userId: MOCK_USER_WITH_ACCOUNTS.id, redirectUri: 'http://localhost:9999/callback', codeChallenge: 'challenge', - scope: 'mcp other', + scope: TEST_OAUTH_SCOPE, resource: 'https://sourcebot.test/api/mcp', dpopJkt: 'dpop-thumbprint', }); @@ -70,7 +71,7 @@ describe('generateAndStoreAuthCode', () => { userId: MOCK_USER_WITH_ACCOUNTS.id, redirectUri: 'http://localhost:9999/callback', codeChallenge: 'challenge', - scope: 'mcp other', + scope: TEST_OAUTH_SCOPE, resource: 'https://sourcebot.test/api/mcp', dpopJkt: 'dpop-thumbprint', }), @@ -102,20 +103,20 @@ describe('verifyAndExchangeCode', () => { token: 'sboa_newtoken', refreshToken: 'sbor_newrefresh', expiresIn: expect.any(Number), - scope: SOURCEBOT_MCP_OAUTH_SCOPE, + scope: EMPTY_OAUTH_SCOPE, }); expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); }); test('uses the scope bound to the authorization code when issuing tokens', async () => { prisma.oAuthAuthorizationCode.findUnique.mockResolvedValue({ ...VALID_AUTH_CODE, - scope: 'mcp other', + scope: TEST_OAUTH_SCOPE, }); prisma.oAuthAuthorizationCode.delete.mockResolvedValue(VALID_AUTH_CODE); prisma.oAuthToken.create.mockResolvedValue({} as never); @@ -130,12 +131,12 @@ describe('verifyAndExchangeCode', () => { dpopJkt: null, }); - expect(result).toMatchObject({ scope: 'mcp other' }); + expect(result).toMatchObject({ scope: TEST_OAUTH_SCOPE }); expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: 'mcp other' }), + data: expect.objectContaining({ scope: TEST_OAUTH_SCOPE }), }); expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: 'mcp other' }), + data: expect.objectContaining({ scope: TEST_OAUTH_SCOPE }), }); }); @@ -337,20 +338,20 @@ describe('verifyAndRotateRefreshToken', () => { token: 'sboa_newtoken', refreshToken: 'sbor_newrefresh', expiresIn: expect.any(Number), - scope: SOURCEBOT_MCP_OAUTH_SCOPE, + scope: EMPTY_OAUTH_SCOPE, }); expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); }); - test('defaults legacy empty-scope refresh tokens to the MCP scope during rotation', async () => { + test('preserves empty-scope refresh tokens during rotation', async () => { prisma.oAuthRefreshToken.findUnique.mockResolvedValue({ ...MOCK_REFRESH_TOKEN, - scope: '', + scope: EMPTY_OAUTH_SCOPE, }); prisma.oAuthRefreshToken.delete.mockResolvedValue(MOCK_REFRESH_TOKEN); prisma.oAuthToken.create.mockResolvedValue({} as never); @@ -363,12 +364,12 @@ describe('verifyAndRotateRefreshToken', () => { dpopJkt: null, }); - expect(result).toMatchObject({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }); + expect(result).toMatchObject({ scope: EMPTY_OAUTH_SCOPE }); expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ scope: SOURCEBOT_MCP_OAUTH_SCOPE }), + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); }); diff --git a/packages/web/src/ee/features/oauth/utils.test.ts b/packages/web/src/ee/features/oauth/utils.test.ts index cc0df7575..a27445e19 100644 --- a/packages/web/src/ee/features/oauth/utils.test.ts +++ b/packages/web/src/ee/features/oauth/utils.test.ts @@ -1,6 +1,5 @@ import { expect, test, describe } from 'vitest'; import { - SOURCEBOT_MCP_OAUTH_SCOPE, UNPERMITTED_SCHEMES, } from './constants'; import { @@ -12,21 +11,21 @@ import { describe('OAuth scopes', () => { test('parses and deduplicates space-delimited scope strings', () => { - expect(parseOAuthScopeString(` ${SOURCEBOT_MCP_OAUTH_SCOPE} extra ${SOURCEBOT_MCP_OAUTH_SCOPE} `)).toEqual([ - SOURCEBOT_MCP_OAUTH_SCOPE, + expect(parseOAuthScopeString(' read extra read ')).toEqual([ + 'read', 'extra', ]); }); - test('defaults authorization requests to the Sourcebot MCP scope', () => { + test('defaults authorization requests to no scopes', () => { expect(resolveGrantedOAuthScopes(undefined)).toEqual({ - scopes: [SOURCEBOT_MCP_OAUTH_SCOPE], + scopes: [], }); }); - test('accepts the supported Sourcebot MCP scope', () => { - expect(resolveGrantedOAuthScopes(SOURCEBOT_MCP_OAUTH_SCOPE)).toEqual({ - scopes: [SOURCEBOT_MCP_OAUTH_SCOPE], + test('accepts an empty requested scope string', () => { + expect(resolveGrantedOAuthScopes('')).toEqual({ + scopes: [], }); }); @@ -38,8 +37,8 @@ describe('OAuth scopes', () => { }); test('checks required scopes against token scopes', () => { - expect(hasRequiredOAuthScopes([SOURCEBOT_MCP_OAUTH_SCOPE, 'other'], [SOURCEBOT_MCP_OAUTH_SCOPE])).toBe(true); - expect(hasRequiredOAuthScopes(['other'], [SOURCEBOT_MCP_OAUTH_SCOPE])).toBe(false); + expect(hasRequiredOAuthScopes(['read', 'other'], ['read'])).toBe(true); + expect(hasRequiredOAuthScopes(['other'], ['read'])).toBe(false); }); }); diff --git a/packages/web/src/ee/features/oauth/utils.ts b/packages/web/src/ee/features/oauth/utils.ts index d3878ad28..2fa26f252 100644 --- a/packages/web/src/ee/features/oauth/utils.ts +++ b/packages/web/src/ee/features/oauth/utils.ts @@ -1,5 +1,4 @@ import { - DEFAULT_SOURCEBOT_OAUTH_SCOPES, SOURCEBOT_OAUTH_SCOPES, } from './constants'; @@ -45,9 +44,7 @@ export function resolveGrantedOAuthScopes(requestedScope: string | null | undefi } return { - scopes: requestedScopes.length > 0 - ? requestedScopes - : [...DEFAULT_SOURCEBOT_OAUTH_SCOPES], + scopes: requestedScopes, }; } diff --git a/packages/web/src/middleware/withAuth.test.ts b/packages/web/src/middleware/withAuth.test.ts index 61f58b954..dba6acda7 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -8,9 +8,10 @@ import { OrgRole } from '@sourcebot/db'; import { ErrorCode } from '../lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { userScopedPrismaClientExtension } from '@/prisma'; -import { SOURCEBOT_MCP_OAUTH_SCOPE } from '@/ee/features/oauth/constants'; import { runWithRequestContext } from '@/lib/requestContext'; +const TEST_OAUTH_SCOPE = 'read'; + const mocks = vi.hoisted(() => { return { // Defaults to a empty session. @@ -242,11 +243,11 @@ describe('getAuthenticatedUser', () => { mocks.hasEntitlement.mockReturnValue(true); prisma.oAuthToken.findUnique.mockResolvedValue({ ...MOCK_OAUTH_TOKEN, - scope: `${SOURCEBOT_MCP_OAUTH_SCOPE} other ${SOURCEBOT_MCP_OAUTH_SCOPE}`, + scope: `${TEST_OAUTH_SCOPE} other ${TEST_OAUTH_SCOPE}`, }); setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); const result = await getAuthenticatedUser(); - expect(result?.oauthScopes).toEqual([SOURCEBOT_MCP_OAUTH_SCOPE, 'other']); + expect(result?.oauthScopes).toEqual([TEST_OAUTH_SCOPE, 'other']); }); test('should update lastUsedAt when an OAuth Bearer token is used', async () => { @@ -550,7 +551,7 @@ describe('getAuthContext', () => { const oauthToken = { ...MOCK_OAUTH_TOKEN, user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, - scope: SOURCEBOT_MCP_OAUTH_SCOPE, + scope: TEST_OAUTH_SCOPE, }; prisma.oAuthToken.findUnique.mockResolvedValue(oauthToken); prisma.org.findUnique.mockResolvedValue({ ...MOCK_ORG }); @@ -562,7 +563,7 @@ describe('getAuthContext', () => { }); setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); - const authContext = await getAuthContext({ requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }); + const authContext = await getAuthContext({ requiredOAuthScopes: [TEST_OAUTH_SCOPE] }); expect(authContext).toMatchObject({ user: { id: userId }, @@ -589,12 +590,12 @@ describe('getAuthContext', () => { }); setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); - const authContext = await getAuthContext({ requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }); + const authContext = await getAuthContext({ requiredOAuthScopes: [TEST_OAUTH_SCOPE] }); expect(authContext).toStrictEqual({ statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.OAUTH_INSUFFICIENT_SCOPE, - message: `OAuth access token is missing required scope: ${SOURCEBOT_MCP_OAUTH_SCOPE}`, + message: `OAuth access token is missing required scope: ${TEST_OAUTH_SCOPE}`, }); }); @@ -611,7 +612,7 @@ describe('getAuthContext', () => { prisma.apiKey.findUnique.mockResolvedValue({ ...MOCK_API_KEY, hash: 'apikey', createdById: userId }); setMockHeaders(new Headers({ 'X-Sourcebot-Api-Key': 'sourcebot-apikey' })); - const authContext = await getAuthContext({ requiredOAuthScopes: [SOURCEBOT_MCP_OAUTH_SCOPE] }); + const authContext = await getAuthContext({ requiredOAuthScopes: [TEST_OAUTH_SCOPE] }); expect(authContext).toMatchObject({ user: { id: userId }, diff --git a/packages/web/src/middleware/withAuth.ts b/packages/web/src/middleware/withAuth.ts index 4b2e768cc..e7a33f49d 100644 --- a/packages/web/src/middleware/withAuth.ts +++ b/packages/web/src/middleware/withAuth.ts @@ -12,7 +12,6 @@ import { hasEntitlement, isAnonymousAccessEnabled } from "@/lib/entitlements"; import { hasRequiredOAuthScopes, parseOAuthScopeString } from "@/ee/features/oauth/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; -import { SourcebotOAuthScope } from "@/ee/features/oauth/constants"; const LAST_ACTIVE_AT_THRESHOLD_MS = 5 * 60 * 1000; @@ -33,7 +32,7 @@ type OptionalAuthContext = }; type AuthOptions = { - requiredOAuthScopes?: readonly SourcebotOAuthScope[]; + requiredOAuthScopes?: readonly string[]; }; export const withAuth = async (fn: (params: RequiredAuthContext) => Promise, options: AuthOptions = {}) => { @@ -211,7 +210,11 @@ export const getAuthenticatedUser = async (): Promise<{ user: UserWithAccounts, where: { hash }, data: { lastUsedAt: new Date() }, }); - return { user: oauthToken.user, source: 'oauth', oauthScopes: parseOAuthScopeString(oauthToken.scope) }; + return { + user: oauthToken.user, + source: 'oauth', + oauthScopes: parseOAuthScopeString(oauthToken.scope) + }; } } diff --git a/packages/web/tools/oauthFlow.ts b/packages/web/tools/oauthFlow.ts new file mode 100644 index 000000000..fc593a17c --- /dev/null +++ b/packages/web/tools/oauthFlow.ts @@ -0,0 +1,496 @@ +import { createHash, randomBytes } from 'crypto'; +import { createServer, type ServerResponse } from 'http'; +import { spawn } from 'child_process'; + +type Options = { + baseUrl: string; + scopes: string[]; + resource: string | null; + callbackHost: string; + callbackPort: number; + clientId?: string; + clientName: string; + openBrowser: boolean; + timeoutMs: number; + tokenOnly: boolean; +}; + +type TokenResponse = { + access_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; + error?: string; + error_description?: string; + [key: string]: unknown; +}; + +const DEFAULT_BASE_URL = process.env.AUTH_URL || 'http://localhost:3000'; +const DEFAULT_CALLBACK_HOST = '127.0.0.1'; +const DEFAULT_CALLBACK_PORT = 53682; +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; + +function usage(): string { + return [ + 'Usage: yarn workspace @sourcebot/web tool:oauth-flow [options]', + '', + 'Options:', + ` --base-url Sourcebot URL. Defaults to AUTH_URL or ${DEFAULT_BASE_URL}`, + ' --scope OAuth scope to request. Can be repeated.', + ' --scopes Space-delimited OAuth scopes to request.', + ' --resource Resource parameter. Defaults to /api/mcp.', + ' --no-resource Do not send a resource parameter.', + ` --callback-host Callback host. Defaults to ${DEFAULT_CALLBACK_HOST}.`, + ` --port Callback port. Defaults to ${DEFAULT_CALLBACK_PORT}. Use 0 for random.`, + ' --client-id Use an existing OAuth client instead of dynamic registration.', + ' --client-name Dynamic client name. Defaults to "Sourcebot OAuth CLI".', + ' --no-open Print the authorization URL without opening a browser.', + ' --timeout-ms Callback wait timeout.', + ' --token-only Print only the access token.', + ' --help Show this help text.', + '', + 'Examples:', + ' yarn workspace @sourcebot/web tool:oauth-flow --scope mcp:read', + ' yarn workspace @sourcebot/web tool:oauth-flow --scopes "mcp:read mcp:ask" --token-only', + ' yarn workspace @sourcebot/web tool:oauth-flow --base-url http://localhost:3000 --no-open', + ].join('\n'); +} + +function parseArgs(argv: string[]): Options { + const options: Options = { + baseUrl: stripTrailingSlash(DEFAULT_BASE_URL), + scopes: [], + resource: 'default', + callbackHost: DEFAULT_CALLBACK_HOST, + callbackPort: DEFAULT_CALLBACK_PORT, + clientName: 'Sourcebot OAuth CLI', + openBrowser: true, + timeoutMs: DEFAULT_TIMEOUT_MS, + tokenOnly: false, + } as Options; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = () => { + const value = argv[++i]; + if (!value) { + throw new Error(`Missing value for ${arg}`); + } + return value; + }; + + switch (arg) { + case '--base-url': + options.baseUrl = stripTrailingSlash(next()); + break; + case '--scope': + options.scopes.push(next()); + break; + case '--scopes': + options.scopes.push(...splitScopes(next())); + break; + case '--resource': + options.resource = next(); + break; + case '--no-resource': + options.resource = null; + break; + case '--callback-host': + options.callbackHost = next(); + break; + case '--port': + options.callbackPort = parseInteger(next(), '--port'); + break; + case '--client-id': + options.clientId = next(); + break; + case '--client-name': + options.clientName = next(); + break; + case '--no-open': + options.openBrowser = false; + break; + case '--timeout-ms': + options.timeoutMs = parseInteger(next(), '--timeout-ms'); + break; + case '--token-only': + options.tokenOnly = true; + break; + case '--help': + case '-h': + console.log(usage()); + process.exit(0); + default: + throw new Error(`Unknown argument: ${arg}\n\n${usage()}`); + } + } + + options.scopes = [...new Set(options.scopes.map((scope) => scope.trim()).filter(Boolean))]; + if (options.resource === 'default') { + options.resource = `${options.baseUrl}/api/mcp`; + } + + return options; +} + +function splitScopes(value: string): string[] { + return value.split(/\s+/).map((scope) => scope.trim()).filter(Boolean); +} + +function parseInteger(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${flag} must be a non-negative integer.`); + } + return parsed; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/$/, ''); +} + +function base64UrlSha256(value: string): string { + return createHash('sha256').update(value).digest('base64url'); +} + +function randomUrlSafeString(bytes = 32): string { + return randomBytes(bytes).toString('base64url'); +} + +async function registerClient({ + baseUrl, + clientName, + redirectUri, +}: { + baseUrl: string; + clientName: string; + redirectUri: string; +}): Promise { + const response = await fetch(`${baseUrl}/api/ee/oauth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_name: clientName, + redirect_uris: [redirectUri], + }), + }); + + const body = await readJson(response); + if (!response.ok) { + throw new Error(`Dynamic client registration failed (${response.status}): ${JSON.stringify(body)}`); + } + + const clientId = body.client_id; + if (typeof clientId !== 'string') { + throw new Error(`Dynamic client registration response did not include client_id: ${JSON.stringify(body)}`); + } + + return clientId; +} + +function startCallbackServer({ + host, + port, + expectedState, + timeoutMs, +}: { + host: string; + port: number; + expectedState: string; + timeoutMs: number; +}): Promise<{ redirectUri: string; codePromise: Promise; close: () => Promise }> { + let settled = false; + let resolveCode: (code: string) => void; + let rejectCode: (error: Error) => void; + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + + const settleSuccess = (code: string, response: ServerResponse) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolveCode(code); + writeHtml(response, 200, 'Authorization complete. You can close this tab and return to the terminal.'); + }; + + const settleError = (error: Error, response?: ServerResponse) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + rejectCode(error); + if (response) { + writeHtml(response, 400, 'Authorization failed. You can close this tab and return to the terminal.'); + } + }; + + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? `${host}:${port}`}`); + if (requestUrl.pathname !== '/callback') { + response.writeHead(404, { 'Content-Type': 'text/plain' }); + response.end('Not found'); + return; + } + + const error = requestUrl.searchParams.get('error'); + const errorDescription = requestUrl.searchParams.get('error_description'); + if (error) { + const message = errorDescription ? `${error}: ${errorDescription}` : error; + settleError(new Error(`Authorization failed: ${message}`), response); + return; + } + + const state = requestUrl.searchParams.get('state'); + if (state !== expectedState) { + settleError(new Error('Authorization callback state did not match.'), response); + return; + } + + const code = requestUrl.searchParams.get('code'); + if (!code) { + settleError(new Error('Authorization callback did not include a code.'), response); + return; + } + + settleSuccess(code, response); + }); + + const timeout = setTimeout(() => { + settleError(new Error(`Timed out after ${timeoutMs}ms waiting for OAuth callback.`)); + void closeServer(server); + }, timeoutMs); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.off('error', reject); + const address = server.address(); + const actualPort = typeof address === 'object' && address ? address.port : port; + resolve({ + redirectUri: `http://${host}:${actualPort}/callback`, + codePromise: codePromise.finally(() => closeServer(server)), + close: () => closeServer(server), + }); + }); + }); +} + +function writeHtml(response: ServerResponse, status: number, message: string): void { + response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }); + response.end(`Sourcebot OAuth

${escapeHtml(message)}

`); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function exchangeCode({ + baseUrl, + clientId, + redirectUri, + code, + codeVerifier, + resource, +}: { + baseUrl: string; + clientId: string; + redirectUri: string; + code: string; + codeVerifier: string; + resource: string | null; +}): Promise { + const formData = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + redirect_uri: redirectUri, + code, + code_verifier: codeVerifier, + }); + + if (resource) { + formData.set('resource', resource); + } + + const response = await fetch(`${baseUrl}/api/ee/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData, + }); + + const body = await readJson(response) as TokenResponse; + if (!response.ok) { + throw new Error(`Token exchange failed (${response.status}): ${JSON.stringify(body)}`); + } + + return body; +} + +async function readJson(response: Response): Promise> { + const text = await response.text(); + if (!text) { + return {}; + } + + try { + return JSON.parse(text) as Record; + } catch { + throw new Error(`Expected JSON response from ${response.url}, got: ${text}`); + } +} + +function buildAuthorizeUrl({ + baseUrl, + clientId, + redirectUri, + codeChallenge, + state, + scopes, + resource, +}: { + baseUrl: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + state: string; + scopes: string[]; + resource: string | null; +}): string { + const authorizeUrl = new URL(`${baseUrl}/oauth/authorize`); + authorizeUrl.searchParams.set('client_id', clientId); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('code_challenge', codeChallenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('state', state); + + if (scopes.length > 0) { + authorizeUrl.searchParams.set('scope', scopes.join(' ')); + } + + if (resource) { + authorizeUrl.searchParams.set('resource', resource); + } + + return authorizeUrl.toString(); +} + +function openBrowser(url: string): void { + const command = process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'cmd' + : 'xdg-open'; + const args = process.platform === 'win32' + ? ['/c', 'start', '', url] + : [url]; + + const child = spawn(command, args, { + detached: true, + stdio: 'ignore', + }); + child.unref(); +} + +async function closeServer(server: ReturnType): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + const state = randomUrlSafeString(24); + const codeVerifier = randomUrlSafeString(32); + const codeChallenge = base64UrlSha256(codeVerifier); + + const callbackServer = await startCallbackServer({ + host: options.callbackHost, + port: options.callbackPort, + expectedState: state, + timeoutMs: options.timeoutMs, + }); + + try { + const clientId = options.clientId ?? await registerClient({ + baseUrl: options.baseUrl, + clientName: options.clientName, + redirectUri: callbackServer.redirectUri, + }); + + const authorizeUrl = buildAuthorizeUrl({ + baseUrl: options.baseUrl, + clientId, + redirectUri: callbackServer.redirectUri, + codeChallenge, + state, + scopes: options.scopes, + resource: options.resource, + }); + + console.error(`Registered client: ${clientId}`); + console.error(`Redirect URI: ${callbackServer.redirectUri}`); + console.error(`Scopes: ${options.scopes.length > 0 ? options.scopes.join(' ') : '(none requested)'}`); + console.error(`Resource: ${options.resource ?? '(none requested)'}`); + console.error(`Authorization URL:\n${authorizeUrl}\n`); + + if (options.openBrowser) { + openBrowser(authorizeUrl); + console.error('Opened authorization URL in your browser.'); + } + + console.error('Waiting for OAuth callback...'); + const code = await callbackServer.codePromise; + console.error('Received authorization code. Exchanging for token...'); + + const tokenResponse = await exchangeCode({ + baseUrl: options.baseUrl, + clientId, + redirectUri: callbackServer.redirectUri, + code, + codeVerifier, + resource: options.resource, + }); + + if (options.tokenOnly) { + if (!tokenResponse.access_token) { + throw new Error(`Token response did not include access_token: ${JSON.stringify(tokenResponse)}`); + } + console.log(tokenResponse.access_token); + return; + } + + console.log(JSON.stringify(tokenResponse, null, 2)); + } finally { + await callbackServer.close(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +});