diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index 7fa98e17..9d8fb892 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -105,7 +105,7 @@ describe('oauth2', () => { let capturedPort = 0; const mockHttpServer = { - listen: vi.fn((port: number, callback?: () => void) => { + listen: vi.fn((port: number, _host: string, callback?: () => void) => { capturedPort = port; if (callback) { callback(); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 3c3f7055..ac45177c 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -139,13 +139,33 @@ export async function getOauthClient( } else { const webLogin = await authWithWeb(client); - // This does basically nothing, as it isn't show to the user. console.log( `\n\nCode Assist login required.\n` + `Attempting to open authentication page in your browser.\n` + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n`, ); - await open(webLogin.authUrl); + try { + // Attempt to open the authentication URL in the default browser. + // We do not use the `wait` option here because the main script's execution + // is already paused by `loginCompletePromise`, which awaits the server callback. + const childProcess = await open(webLogin.authUrl); + + // IMPORTANT: Attach an error handler to the returned child process. + // Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found + // in a minimal Docker container), it will emit an unhandled 'error' event, + // causing the entire Node.js process to crash. + childProcess.on('error', (_) => { + console.error( + 'Failed to open browser automatically. Please open the URL manually:', + ); + console.error(webLogin.authUrl); + }); + } catch (err) { + console.error( + 'An unexpected error occurred while trying to open the browser:', + err, + ); + } console.log('Waiting for authentication...'); await webLogin.loginCompletePromise; @@ -202,6 +222,12 @@ async function authWithUserCode(client: OAuth2Client): Promise { async function authWithWeb(client: OAuth2Client): Promise { const port = await getAvailablePort(); + // The hostname used for the HTTP server binding (e.g., '0.0.0.0' in Docker). + const host = process.env.OAUTH_CALLBACK_HOST || 'localhost'; + // The `redirectUri` sent to Google's authorization server MUST use a loopback IP literal + // (i.e., 'localhost' or '127.0.0.1'). This is a strict security policy for credentials of + // type 'Desktop app' or 'Web application' (when using loopback flow) to mitigate + // authorization code interception attacks. const redirectUri = `http://localhost:${port}/oauth2callback`; const state = crypto.randomBytes(32).toString('hex'); const authUrl = client.generateAuthUrl({ @@ -259,7 +285,7 @@ async function authWithWeb(client: OAuth2Client): Promise { server.close(); } }); - server.listen(port); + server.listen(port, host); }); return { @@ -272,6 +298,16 @@ export function getAvailablePort(): Promise { return new Promise((resolve, reject) => { let port = 0; try { + const portStr = process.env.OAUTH_CALLBACK_PORT; + if (portStr) { + port = parseInt(portStr, 10); + if (isNaN(port) || port <= 0 || port > 65535) { + return reject( + new Error(`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`), + ); + } + return resolve(port); + } const server = net.createServer(); server.listen(0, () => { const address = server.address()! as net.AddressInfo;