diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a565fc3..12f6261c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,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 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 ''; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 250d67c9d..73d0f3e4b 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -732,6 +732,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 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 9761ebe24..3e46e17bd 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -17,7 +17,6 @@ export const MOCK_ORG: Org = { updatedAt: new Date(), isOnboarded: true, imageUrl: null, - metadata: null, memberApprovalRequired: false, isCredentialsLoginEnabled: true, isEmailCodeLoginEnabled: false, 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 0b568d6c8..d58093bca 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'; import { SUPPORTED_DPOP_SIGNING_ALGS } from '@/ee/features/oauth/dpop'; @@ -25,6 +25,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'], dpop_signing_alg_values_supported: SUPPORTED_DPOP_SIGNING_ALGS, 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 dc3cd8103..052973232 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,6 +37,7 @@ export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { p authorization_servers: [ issuer ], + scopes_supported: SOURCEBOT_OAUTH_SCOPES, bearer_methods_supported: ['header'], }); }); 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 7d7d375dd..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,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_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. @@ -20,20 +21,35 @@ 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.append( - 'WWW-Authenticate', - `Bearer realm="Sourcebot", resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"` - ); - response.headers.append( - 'WWW-Authenticate', - `DPoP 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.append('WWW-Authenticate', mcpOAuthChallenge('Bearer', error)); + response.headers.append('WWW-Authenticate', mcpOAuthChallenge('DPoP', error)); } return response; } +function mcpOAuthChallenge(scheme: 'Bearer' | 'DPoP', error: ServiceError): string { + const issuer = env.AUTH_URL.replace(/\/$/, ''); + const params = [ + 'realm="Sourcebot"', + `resource_metadata_uri="${issuer}/.well-known/oauth-protected-resource/api/mcp"`, + ]; + const scope = SOURCEBOT_OAUTH_SCOPES.join(' '); + if (scope) { + params.push(`scope="${scope}"`); + } + + if (error.errorCode === ErrorCode.OAUTH_INSUFFICIENT_SCOPE) { + params.push('error="insufficient_scope"'); + params.push(`error_description="${error.message}"`); + } + + return `${scheme} ${params.join(', ')}`; +} + // @see: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management interface McpSession { server: McpServer; 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 9de18feb6..4d656a7f5 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'; @@ -77,8 +76,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => { access_token: result.token, refresh_token: result.refreshToken, token_type: result.dpopJkt ? DPOP_TOKEN_TYPE : 'Bearer', - expires_in: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, - scope: '', + expires_in: result.expiresIn, + scope: result.scope, }); } @@ -110,8 +109,8 @@ export const POST = oauthApiHandler(async (request: NextRequest) => { access_token: result.token, refresh_token: result.refreshToken, token_type: result.dpopJkt ? DPOP_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 65bc0431e..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'; @@ -17,6 +17,7 @@ interface ConsentScreenProps { clientLogoUri: string | null; redirectUri: string; codeChallenge: string; + requestedScope: string | undefined; resource: string | null; dpopJkt: string | null; state: string | undefined; @@ -29,6 +30,7 @@ export function ConsentScreen({ clientLogoUri, redirectUri, codeChallenge, + requestedScope, resource, dpopJkt, state, @@ -45,7 +47,7 @@ export function ConsentScreen({ const onApprove = async () => { captureEvent('wa_oauth_authorization_approved', { clientId, clientName }); setPending('approve'); - const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, resource, dpopJkt, state }); + const result = await approveAuthorization({ clientId, redirectUri, codeChallenge, requestedScope, resource, dpopJkt, 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 67a0d8356..210b6b0fe 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/utils'; import { isValidDpopJkt } from '@/ee/features/oauth/dpop'; export const dynamic = 'force-dynamic'; @@ -16,6 +17,7 @@ interface AuthorizePageProps { code_challenge_method?: string; response_type?: string; state?: string; + scope?: string; resource?: string | string[]; dpop_jkt?: string | string[]; }>; @@ -27,7 +29,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, dpop_jkt: _dpopJkt } = params; + const { client_id, redirect_uri, code_challenge, code_challenge_method, response_type, state, scope, resource: _resource, dpop_jkt: _dpopJkt } = 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. @@ -50,6 +52,11 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps return ; } + const grantedScopes = resolveGrantedOAuthScopes(scope); + if ('error' in grantedScopes) { + return ; + } + if (dpopJkt && !isValidDpopJkt(dpopJkt)) { return ; } @@ -80,6 +87,7 @@ export default async function AuthorizePage({ searchParams }: AuthorizePageProps clientLogoUri={client.logoUri} redirectUri={redirect_uri!} codeChallenge={code_challenge!} + requestedScope={scope} resource={resource ?? null} dpopJkt={dpopJkt ?? null} state={state} diff --git a/packages/web/src/ee/features/oauth/actions.ts b/packages/web/src/ee/features/oauth/actions.ts index 1f53a4bac..c92421179 100644 --- a/packages/web/src/ee/features/oauth/actions.ts +++ b/packages/web/src/ee/features/oauth/actions.ts @@ -4,8 +4,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 { 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'; import { StatusCodes } from 'http-status-codes'; export interface ConnectedOauthClient { @@ -40,6 +42,7 @@ export const approveAuthorization = async ({ clientId, redirectUri, codeChallenge, + requestedScope, resource, dpopJkt, state, @@ -47,17 +50,27 @@ export const approveAuthorization = async ({ clientId: string; redirectUri: string; codeChallenge: string; + requestedScope: string | undefined; resource: string | null; dpopJkt: 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; + } + if (dpopJkt !== null && !isValidDpopJkt(dpopJkt)) { return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_QUERY_PARAMS, message: 'Invalid dpop_jkt parameter.', - }; + } satisfies ServiceError; } const rawCode = await generateAndStoreAuthCode({ @@ -65,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 3315d3adf..6f2b0835d 100644 --- a/packages/web/src/ee/features/oauth/constants.ts +++ b/packages/web/src/ee/features/oauth/constants.ts @@ -3,20 +3,5 @@ export const OAUTH_NOT_SUPPORTED_ERROR_MESSAGE = 'OAuth is not supported on this export const UNPERMITTED_SCHEMES = /^(javascript|data|vbscript):/i; -/** - * 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; - } -} \ No newline at end of file +export const SOURCEBOT_OAUTH_SCOPES = [] as const; +export type SourcebotOAuthScope = (typeof SOURCEBOT_OAUTH_SCOPES)[number]; diff --git a/packages/web/src/ee/features/oauth/server.test.ts b/packages/web/src/ee/features/oauth/server.test.ts index 85eb1c8b5..2fce8597e 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'; vi.mock('@/prisma', async () => { const actual = await vi.importActual('@/__mocks__/prisma'); @@ -23,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', @@ -30,6 +32,7 @@ const VALID_AUTH_CODE = { redirectUri: 'http://localhost:9999/callback', // SHA-256('myverifier') base64url codeChallenge: 'Eb223qLjTQNFkRjCVsrDbsBk5ycPKwHdbHNRX99tTeQ', + scope: EMPTY_OAUTH_SCOPE, resource: null, dpopJkt: null, expiresAt: new Date(Date.now() + 10 * 60 * 1000), @@ -43,6 +46,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: TEST_OAUTH_SCOPE, + 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: TEST_OAUTH_SCOPE, + resource: 'https://sourcebot.test/api/mcp', + dpopJkt: 'dpop-thumbprint', + }), + }); + }); +}); + // --------------------------------------------------------------------------- // verifyAndExchangeCode // --------------------------------------------------------------------------- @@ -67,6 +103,40 @@ describe('verifyAndExchangeCode', () => { token: 'sboa_newtoken', refreshToken: 'sbor_newrefresh', expiresIn: expect.any(Number), + scope: EMPTY_OAUTH_SCOPE, + }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + 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: TEST_OAUTH_SCOPE, + }); + 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: TEST_OAUTH_SCOPE }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: TEST_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: TEST_OAUTH_SCOPE }), }); }); @@ -268,6 +338,38 @@ describe('verifyAndRotateRefreshToken', () => { token: 'sboa_newtoken', refreshToken: 'sbor_newrefresh', expiresIn: expect.any(Number), + scope: EMPTY_OAUTH_SCOPE, + }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), + }); + }); + + test('preserves empty-scope refresh tokens during rotation', async () => { + prisma.oAuthRefreshToken.findUnique.mockResolvedValue({ + ...MOCK_REFRESH_TOKEN, + scope: EMPTY_OAUTH_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, + dpopJkt: null, + }); + + expect(result).toMatchObject({ scope: EMPTY_OAUTH_SCOPE }); + expect(prisma.oAuthToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), + }); + expect(prisma.oAuthRefreshToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ scope: EMPTY_OAUTH_SCOPE }), }); }); diff --git a/packages/web/src/ee/features/oauth/server.ts b/packages/web/src/ee/features/oauth/server.ts index be00b55ef..9253ce93a 100644 --- a/packages/web/src/ee/features/oauth/server.ts +++ b/packages/web/src/ee/features/oauth/server.ts @@ -19,6 +19,7 @@ export async function generateAndStoreAuthCode({ userId, redirectUri, codeChallenge, + scope, resource, dpopJkt, }: { @@ -26,6 +27,7 @@ export async function generateAndStoreAuthCode({ userId: string; redirectUri: string; codeChallenge: string; + scope: string; resource: string | null; dpopJkt: string | null; }): Promise { @@ -39,6 +41,7 @@ export async function generateAndStoreAuthCode({ userId, redirectUri, codeChallenge, + scope, resource, dpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_AUTHORIZATION_CODE_TTL_SECONDS * 1000), @@ -64,7 +67,7 @@ export async function verifyAndExchangeCode({ codeVerifier: string; resource: string | null; dpopJkt: string | null; -}): Promise<{ token: string; refreshToken: string; expiresIn: number; dpopJkt: string | null } | { error: string; errorDescription: string }> { +}): Promise<{ token: string; refreshToken: string; expiresIn: number; scope: string; dpopJkt: string | null } | { error: string; errorDescription: string }> { const codeHash = hashSecret(rawCode); const authCode = await __unsafePrisma.oAuthAuthorizationCode.findUnique({ @@ -120,6 +123,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([ @@ -128,6 +132,7 @@ export async function verifyAndExchangeCode({ hash, clientId, userId: authCode.userId, + scope, resource: authCode.resource, dpopJkt: tokenDpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), @@ -138,6 +143,7 @@ export async function verifyAndExchangeCode({ hash: refreshHash, clientId, userId: authCode.userId, + scope, resource: authCode.resource, dpopJkt: tokenDpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), @@ -145,7 +151,7 @@ export async function verifyAndExchangeCode({ }), ]); - return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, 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. @@ -161,7 +167,7 @@ export async function verifyAndRotateRefreshToken({ clientId: string; resource: string | null; dpopJkt: string | null; -}): Promise<{ token: string; refreshToken: string; expiresIn: number; dpopJkt: string | null } | { error: string; errorDescription: string }> { +}): Promise<{ token: string; refreshToken: string; expiresIn: number; scope: string; dpopJkt: string | null } | { error: string; errorDescription: string }> { if (!rawRefreshToken.startsWith(OAUTH_REFRESH_TOKEN_PREFIX)) { return { error: 'invalid_grant', errorDescription: 'Refresh token is invalid.' }; } @@ -193,6 +199,7 @@ export async function verifyAndRotateRefreshToken({ const { token, hash: newTokenHash } = generateOAuthToken(); const { token: refreshToken, hash: newRefreshHash } = generateOAuthRefreshToken(); + const scope = existing.scope; const tokenDpopJkt = existing.dpopJkt ?? dpopJkt; await __unsafePrisma.$transaction([ @@ -202,6 +209,7 @@ export async function verifyAndRotateRefreshToken({ hash: newTokenHash, clientId, userId: existing.userId, + scope, resource: existing.resource, dpopJkt: tokenDpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000), @@ -212,6 +220,7 @@ export async function verifyAndRotateRefreshToken({ hash: newRefreshHash, clientId, userId: existing.userId, + scope, resource: existing.resource, dpopJkt: tokenDpopJkt, expiresAt: new Date(Date.now() + env.OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000), @@ -219,7 +228,7 @@ export async function verifyAndRotateRefreshToken({ }), ]); - return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, dpopJkt: tokenDpopJkt }; + return { token, refreshToken, expiresIn: env.OAUTH_ACCESS_TOKEN_TTL_SECONDS, scope, dpopJkt: tokenDpopJkt }; } // Revokes an access token or refresh token by hashing it and deleting the DB record. diff --git a/packages/web/src/ee/features/oauth/constants.test.ts b/packages/web/src/ee/features/oauth/utils.test.ts similarity index 71% rename from packages/web/src/ee/features/oauth/constants.test.ts rename to packages/web/src/ee/features/oauth/utils.test.ts index 4918651b9..a27445e19 100644 --- a/packages/web/src/ee/features/oauth/constants.test.ts +++ b/packages/web/src/ee/features/oauth/utils.test.ts @@ -1,5 +1,46 @@ import { expect, test, describe } from 'vitest'; -import { UNPERMITTED_SCHEMES, isPermittedRedirectUrl } from './constants'; +import { + UNPERMITTED_SCHEMES, +} from './constants'; +import { + hasRequiredOAuthScopes, + isPermittedRedirectUrl, + parseOAuthScopeString, + resolveGrantedOAuthScopes, +} from './utils'; + +describe('OAuth scopes', () => { + test('parses and deduplicates space-delimited scope strings', () => { + expect(parseOAuthScopeString(' read extra read ')).toEqual([ + 'read', + 'extra', + ]); + }); + + test('defaults authorization requests to no scopes', () => { + expect(resolveGrantedOAuthScopes(undefined)).toEqual({ + scopes: [], + }); + }); + + test('accepts an empty requested scope string', () => { + expect(resolveGrantedOAuthScopes('')).toEqual({ + scopes: [], + }); + }); + + 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(['read', 'other'], ['read'])).toBe(true); + expect(hasRequiredOAuthScopes(['other'], ['read'])).toBe(false); + }); +}); describe('UNPERMITTED_SCHEMES', () => { // Dangerous schemes that must be blocked 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..2fa26f252 --- /dev/null +++ b/packages/web/src/ee/features/oauth/utils.ts @@ -0,0 +1,67 @@ +import { + 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, + }; +} + +/** + * 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/lib/apiHandler.test.ts b/packages/web/src/lib/apiHandler.test.ts index 087f2b116..681f64cdb 100644 --- a/packages/web/src/lib/apiHandler.test.ts +++ b/packages/web/src/lib/apiHandler.test.ts @@ -13,7 +13,7 @@ describe('apiHandler', () => { method: 'POST', }); - const handler = apiHandler(async () => { + const handler = apiHandler(async (_request: NextRequest) => { expect(getCurrentRequest()).toBe(request); await Promise.resolve(); expect(getCurrentRequest()).toBe(request); 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 f8e59c724..dba6acda7 100644 --- a/packages/web/src/middleware/withAuth.test.ts +++ b/packages/web/src/middleware/withAuth.test.ts @@ -10,6 +10,8 @@ import { StatusCodes } from 'http-status-codes'; import { userScopedPrismaClientExtension } from '@/prisma'; import { runWithRequestContext } from '@/lib/requestContext'; +const TEST_OAUTH_SCOPE = 'read'; + const mocks = vi.hoisted(() => { return { // Defaults to a empty session. @@ -237,6 +239,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: `${TEST_OAUTH_SCOPE} other ${TEST_OAUTH_SCOPE}`, + }); + setMockHeaders(new Headers({ 'Authorization': 'Bearer sboa_oauthtoken' })); + const result = await getAuthenticatedUser(); + expect(result?.oauthScopes).toEqual([TEST_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); @@ -530,6 +543,84 @@ 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); + const oauthToken = { + ...MOCK_OAUTH_TOKEN, + user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, + scope: TEST_OAUTH_SCOPE, + }; + prisma.oAuthToken.findUnique.mockResolvedValue(oauthToken); + 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: [TEST_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); + const oauthToken = { + ...MOCK_OAUTH_TOKEN, + user: { ...MOCK_USER_WITH_ACCOUNTS, id: userId }, + scope: 'other', + }; + prisma.oAuthToken.findUnique.mockResolvedValue(oauthToken); + 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: [TEST_OAUTH_SCOPE] }); + + expect(authContext).toStrictEqual({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.OAUTH_INSUFFICIENT_SCOPE, + message: `OAuth access token is missing required scope: ${TEST_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: [TEST_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 a501c0ad5..e7a33f49d 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/utils"; import { DPOP_AUTH_SCHEME, DPOP_PROOF_HEADER, verifyDpopProof } from "@/ee/features/oauth/dpop"; import { getCurrentRequest } from "@/lib/requestContext"; @@ -30,9 +31,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; @@ -47,8 +51,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; } @@ -63,7 +67,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({ @@ -101,6 +105,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) { @@ -198,7 +210,11 @@ 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) + }; } } 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); +});