fix(mcp): replace hardcoded port 7777 with dynamic port allocation for OAuth (#12520)

This commit is contained in:
Chris Coutinho
2025-11-04 08:16:56 +01:00
committed by GitHub
parent cb2880cb93
commit ab73051298
2 changed files with 129 additions and 75 deletions

View File

@@ -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),

View File

@@ -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<OAuthClientRegistrationResponse> {
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<OAuthAuthorizationResponse> {
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<number>;
response: Promise<OAuthAuthorizationResponse>;
} {
let portResolve: (port: number) => void;
let portReject: (error: Error) => void;
const portPromise = new Promise<number>((resolve, reject) => {
portResolve = resolve;
portReject = reject;
});
if (url.pathname !== REDIRECT_PATH) {
res.writeHead(404);
res.end('Not found');
return;
}
const responsePromise = new Promise<OAuthAuthorizationResponse>(
(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(`
<html>
<body>
<h1>Authentication Failed</h1>
@@ -294,28 +308,28 @@ export class MCPOAuthProvider {
</body>
</html>
`);
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(`
<html>
<body>
<h1>Authentication Successful!</h1>
@@ -325,31 +339,57 @@ export class MCPOAuthProvider {
</html>
`);
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<OAuthTokenResponse> {
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,
);