mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(mcp): replace hardcoded port 7777 with dynamic port allocation for OAuth (#12520)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user