From ab73051298b53d7748e93b88d439e775b08a7bac Mon Sep 17 00:00:00 2001 From: Chris Coutinho <12901868+cbcoutinho@users.noreply.github.com> Date: Tue, 4 Nov 2025 08:16:56 +0100 Subject: [PATCH] fix(mcp): replace hardcoded port 7777 with dynamic port allocation for OAuth (#12520) --- packages/core/src/mcp/oauth-provider.test.ts | 3 +- packages/core/src/mcp/oauth-provider.ts | 201 ++++++++++++------- 2 files changed, 129 insertions(+), 75 deletions(-) diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 69e9e9374a..e462cea7bd 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -104,11 +104,12 @@ const createMockResponse = (options: { return response; }; -// Define a reusable mock server with .listen, .close, and .on methods +// Define a reusable mock server with .listen, .close, .on, and .address methods const mockHttpServer = { listen: vi.fn(), close: vi.fn(), on: vi.fn(), + address: vi.fn(() => ({ address: 'localhost', family: 'IPv4', port: 7777 })), }; vi.mock('node:http', () => ({ createServer: vi.fn(() => mockHttpServer), diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 0069a627fa..06f5a95cf8 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -6,6 +6,7 @@ import * as http from 'node:http'; import * as crypto from 'node:crypto'; +import type * as net from 'node:net'; import { URL } from 'node:url'; import type { EventEmitter } from 'node:events'; import { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; @@ -89,7 +90,6 @@ interface PKCEParams { state: string; } -const REDIRECT_PORT = 7777; const REDIRECT_PATH = '/oauth/callback'; const HTTP_OK = 200; @@ -108,14 +108,16 @@ export class MCPOAuthProvider { * * @param registrationUrl The client registration endpoint URL * @param config OAuth configuration + * @param redirectPort The port to use for the redirect URI * @returns The registered client information */ private async registerClient( registrationUrl: string, config: MCPOAuthConfig, + redirectPort: number, ): Promise { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; const registrationRequest: OAuthClientRegistrationRequest = { client_name: 'Gemini CLI MCP Client', @@ -259,32 +261,44 @@ export class MCPOAuthProvider { /** * Start a local HTTP server to handle OAuth callback. + * The server will listen on the specified port (or port 0 for OS assignment). * * @param expectedState The state parameter to validate - * @returns Promise that resolves with the authorization code + * @returns Object containing the port (available immediately) and a promise for the auth response */ - private async startCallbackServer( - expectedState: string, - ): Promise { - return new Promise((resolve, reject) => { - const server = http.createServer( - async (req: http.IncomingMessage, res: http.ServerResponse) => { - try { - const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`); + private startCallbackServer(expectedState: string): { + port: Promise; + response: Promise; + } { + let portResolve: (port: number) => void; + let portReject: (error: Error) => void; + const portPromise = new Promise((resolve, reject) => { + portResolve = resolve; + portReject = reject; + }); - if (url.pathname !== REDIRECT_PATH) { - res.writeHead(404); - res.end('Not found'); - return; - } + const responsePromise = new Promise( + (resolve, reject) => { + let serverPort: number; - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const error = url.searchParams.get('error'); + const server = http.createServer( + async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + const url = new URL(req.url!, `http://localhost:${serverPort}`); - if (error) { - res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); - res.end(` + if (url.pathname !== REDIRECT_PATH) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); + res.end(`

Authentication Failed

@@ -294,28 +308,28 @@ export class MCPOAuthProvider { `); - server.close(); - reject(new Error(`OAuth error: ${error}`)); - return; - } + server.close(); + reject(new Error(`OAuth error: ${error}`)); + return; + } - if (!code || !state) { - res.writeHead(400); - res.end('Missing code or state parameter'); - return; - } + if (!code || !state) { + res.writeHead(400); + res.end('Missing code or state parameter'); + return; + } - if (state !== expectedState) { - res.writeHead(400); - res.end('Invalid state parameter'); - server.close(); - reject(new Error('State mismatch - possible CSRF attack')); - return; - } + if (state !== expectedState) { + res.writeHead(400); + res.end('Invalid state parameter'); + server.close(); + reject(new Error('State mismatch - possible CSRF attack')); + return; + } - // Send success response to browser - res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); - res.end(` + // Send success response to browser + res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); + res.end(`

Authentication Successful!

@@ -325,31 +339,57 @@ export class MCPOAuthProvider { `); - server.close(); - resolve({ code, state }); - } catch (error) { - server.close(); - reject(error); - } - }, - ); - - server.on('error', reject); - server.listen(REDIRECT_PORT, () => { - debugLogger.log( - `OAuth callback server listening on port ${REDIRECT_PORT}`, + server.close(); + resolve({ code, state }); + } catch (error) { + server.close(); + reject(error); + } + }, ); - }); - // Timeout after 5 minutes - setTimeout( - () => { - server.close(); - reject(new Error('OAuth callback timeout')); - }, - 5 * 60 * 1000, - ); - }); + server.on('error', (error) => { + portReject(error); + reject(error); + }); + + // Determine which port to use (env var or OS-assigned) + const portStr = process.env['OAUTH_CALLBACK_PORT']; + let listenPort = 0; // Default to OS-assigned port + if (portStr) { + const envPort = parseInt(portStr, 10); + if (isNaN(envPort) || envPort <= 0 || envPort > 65535) { + const error = new Error( + `Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`, + ); + portReject(error); + reject(error); + return; + } + listenPort = envPort; + } + + server.listen(listenPort, () => { + const address = server.address() as net.AddressInfo; + serverPort = address.port; + debugLogger.log( + `OAuth callback server listening on port ${serverPort}`, + ); + portResolve(serverPort); // Resolve port promise immediately + }); + + // Timeout after 5 minutes + setTimeout( + () => { + server.close(); + reject(new Error('OAuth callback timeout')); + }, + 5 * 60 * 1000, + ); + }, + ); + + return { port: portPromise, response: responsePromise }; } /** @@ -357,16 +397,18 @@ export class MCPOAuthProvider { * * @param config OAuth configuration * @param pkceParams PKCE parameters + * @param redirectPort The port to use for the redirect URI * @param mcpServerUrl The MCP server URL to use as the resource parameter * @returns The authorization URL */ private buildAuthorizationUrl( config: MCPOAuthConfig, pkceParams: PKCEParams, + redirectPort: number, mcpServerUrl?: string, ): string { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; const params = new URLSearchParams({ client_id: config.clientId!, @@ -413,6 +455,7 @@ export class MCPOAuthProvider { * @param config OAuth configuration * @param code Authorization code * @param codeVerifier PKCE code verifier + * @param redirectPort The port to use for the redirect URI * @param mcpServerUrl The MCP server URL to use as the resource parameter * @returns The token response */ @@ -420,10 +463,11 @@ export class MCPOAuthProvider { config: MCPOAuthConfig, code: string, codeVerifier: string, + redirectPort: number, mcpServerUrl?: string, ): Promise { const redirectUri = - config.redirectUri || `http://localhost:${REDIRECT_PORT}${REDIRECT_PATH}`; + config.redirectUri || `http://localhost:${redirectPort}${REDIRECT_PATH}`; const params = new URLSearchParams({ grant_type: 'authorization_code', @@ -745,6 +789,18 @@ export class MCPOAuthProvider { } } + // Generate PKCE parameters + const pkceParams = this.generatePKCEParams(); + + // Start callback server first to allocate port + // This ensures we only create one server and eliminates race conditions + const callbackServer = this.startCallbackServer(pkceParams.state); + + // Wait for server to start and get the allocated port + // We need this port for client registration and auth URL building + const redirectPort = await callbackServer.port; + debugLogger.debug(`Callback server listening on port ${redirectPort}`); + // If no client ID is provided, try dynamic client registration if (!config.clientId) { let registrationUrl = config.registrationUrl; @@ -771,6 +827,7 @@ export class MCPOAuthProvider { const clientRegistration = await this.registerClient( registrationUrl, config, + redirectPort, ); config.clientId = clientRegistration.client_id; @@ -793,13 +850,11 @@ export class MCPOAuthProvider { ); } - // Generate PKCE parameters - const pkceParams = this.generatePKCEParams(); - // Build authorization URL const authUrl = this.buildAuthorizationUrl( config, pkceParams, + redirectPort, mcpServerUrl, ); @@ -811,10 +866,7 @@ ${authUrl} 💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser. ⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.`); - // Start callback server - const callbackPromise = this.startCallbackServer(pkceParams.state); - - // Open browser securely + // Open browser securely (callback server is already running) try { await openBrowserSecurely(authUrl); } catch (error) { @@ -825,7 +877,7 @@ ${authUrl} } // Wait for callback - const { code } = await callbackPromise; + const { code } = await callbackServer.response; debugLogger.debug( '✓ Authorization code received, exchanging for tokens...', @@ -836,6 +888,7 @@ ${authUrl} config, code, pkceParams.codeVerifier, + redirectPort, mcpServerUrl, );