From 4309559a55a6d5b315fbfacf316649097a30aaf4 Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Thu, 14 May 2026 13:42:13 -0400 Subject: [PATCH] fix(core): prevent unhandled rejection on OAuth callback timeout --- packages/core/src/utils/oauth-flow.ts | 236 +++++++++++++------------- 1 file changed, 116 insertions(+), 120 deletions(-) diff --git a/packages/core/src/utils/oauth-flow.ts b/packages/core/src/utils/oauth-flow.ts index e8fff4d44c..7bfdabf2ba 100644 --- a/packages/core/src/utils/oauth-flow.ts +++ b/packages/core/src/utils/oauth-flow.ts @@ -109,7 +109,6 @@ export function startCallbackServer( port: Promise; response: Promise; close: () => void; - server: http.Server; } { let portResolve: (port: number) => void; let portReject: (error: Error) => void; @@ -119,140 +118,138 @@ export function startCallbackServer( }); let timeoutId: NodeJS.Timeout | undefined; - let serverPort: number; + let server: http.Server; - let resolveResponse: (value: OAuthAuthorizationResponse) => void; - let rejectResponse: (reason: unknown) => void; const responsePromise = new Promise( (resolve, reject) => { - resolveResponse = resolve; - rejectResponse = reject; - }, - ); + let serverPort: number; - const server = http.createServer( - async (req: http.IncomingMessage, res: http.ServerResponse) => { - try { - const url = new URL(req.url ?? '', 'http://localhost'); + server = http.createServer( + async (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + const url = new URL(req.url ?? '', 'http://localhost'); - if (url.pathname !== REDIRECT_PATH) { - res.writeHead(404); - res.end('Not found'); - return; - } + 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'); + 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

-

Error: ${error.replace(//g, '>')}

-

${(url.searchParams.get('error_description') || '').replace(//g, '>')}

-

You can close this window.

- - - `); - server.close(); - rejectResponse(new Error(`OAuth error: ${error}`)); - return; - } + if (error) { + res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authentication Failed

+

Error: ${error.replace(//g, '>')}

+

${(url.searchParams.get('error_description') || '').replace(//g, '>')}

+

You can close this window.

+ + + `); + 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(); - rejectResponse(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(` - - -

Authentication Successful!

-

You can close this window and return to Gemini CLI.

- - - - `); + // Send success response to browser + res.writeHead(HTTP_OK, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authentication Successful!

+

You can close this window and return to Gemini CLI.

+ + + + `); - server.close(); - resolveResponse({ code, state }); - } catch (error) { - server.close(); - rejectResponse(error); - } - }, - ); - - server.on('error', (error) => { - portReject(error); - rejectResponse(error); - }); - - // Determine which port to use (env var, argument, or OS-assigned) - let listenPort: number | undefined = 0; // Default to OS-assigned port - - const portStr = process.env['OAUTH_CALLBACK_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}"`, + server.close(); + resolve({ code, state }); + } catch (error) { + server.close(); + reject(error); + } + }, ); - portReject(error); - rejectResponse(error); - listenPort = undefined; - } else { - listenPort = envPort; - } - } else if (port !== undefined) { - listenPort = port; - } - if (listenPort !== undefined) { - server.listen(listenPort, () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - 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 - }); - } + server.on('error', (error) => { + portReject(error); + reject(error); + }); - const abortController = new AbortController(); - timeoutId = setTimeout( - () => { - abortController.abort(new Error('OAuth callback timeout')); + // Determine which port to use (env var, argument, or OS-assigned) + let listenPort: number | undefined = 0; // Default to OS-assigned port + + const portStr = process.env['OAUTH_CALLBACK_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); + listenPort = undefined; + } else { + listenPort = envPort; + } + } else if (port !== undefined) { + listenPort = port; + } + + if (listenPort !== undefined) { + server.listen(listenPort, () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + 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 + }); + } + + const abortController = new AbortController(); + timeoutId = setTimeout( + () => { + abortController.abort(new Error('OAuth callback timeout')); + }, + 5 * 60 * 1000, + ); + timeoutId.unref(); + + const onAbort = () => { + server.close(); + reject(abortController.signal.reason); + }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); + + server.on('close', () => { + abortController.signal.removeEventListener('abort', onAbort); + }); }, - 5 * 60 * 1000, ); - timeoutId.unref(); - const onAbort = () => { - server.close(); - rejectResponse(abortController.signal.reason); - }; - abortController.signal.addEventListener('abort', onAbort, { once: true }); - - server.on('close', () => { - abortController.signal.removeEventListener('abort', onAbort); - }); - - // Attach a no-op catch to prevent unhandled rejections if the promise is abandoned. - // The caller can still await it and catch their own errors. + // Safety catch to prevent unhandled rejections if the promise is abandoned responsePromise.catch(() => {}); return { @@ -263,9 +260,8 @@ export function startCallbackServer( clearTimeout(timeoutId); timeoutId = undefined; } - server.close(); + server?.close(); }, - server, }; }