99import crypto from 'node:crypto' ;
1010import http from 'node:http' ;
1111import https from 'node:https' ;
12- import { execFile } from 'node:child_process' ;
12+ import { spawn } from 'node:child_process' ;
1313import { URL } from 'node:url' ;
1414
1515// All three ports must be pre-registered in the Cognito App Client.
@@ -77,18 +77,24 @@ function isPortFree(port: number): Promise<boolean> {
7777 * interpolated into a shell string — to avoid command injection.
7878 */
7979export function openBrowser ( url : string ) : void {
80+ // detached:true + stdio:'ignore' + unref() is the standard Node.js pattern for
81+ // fire-and-forget child processes — the event loop will not wait for them to exit.
82+ const spawnOpts = { detached : true , stdio : 'ignore' as const } ;
83+ let child ;
8084 switch ( process . platform ) {
8185 case 'darwin' :
82- execFile ( 'open' , [ url ] ) ;
86+ child = spawn ( 'open' , [ url ] , spawnOpts ) ;
8387 break ;
8488 case 'win32' :
8589 // Pass the URL via $args[0] so it is never interpolated into the -Command
8690 // string — avoids quote-breaking and injection risk from special characters.
87- execFile ( 'powershell.exe' , [ '-NoProfile' , '-Command' , 'Start-Process $args[0]' , '-args' , url ] ) ;
91+ child = spawn ( 'powershell.exe' , [ '-NoProfile' , '-Command' , 'Start-Process $args[0]' , '-args' , url ] , spawnOpts ) ;
8892 break ;
8993 default :
90- execFile ( 'xdg-open' , [ url ] ) ;
94+ child = spawn ( 'xdg-open' , [ url ] , spawnOpts ) ;
95+ break ;
9196 }
97+ child . unref ( ) ;
9298}
9399
94100// ── Localhost callback server ─────────────────────────────────────────────────
@@ -107,26 +113,33 @@ export function listenForCallback(port: number, expectedState?: string): Promise
107113 const callbackState = parsed . searchParams . get ( 'state' ) ;
108114
109115 if ( expectedState && callbackState !== expectedState ) {
110- res . writeHead ( 400 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
116+ res . writeHead ( 400 , { 'Content-Type' : 'text/html; charset=utf-8' , Connection : 'close' } ) ;
111117 res . end (
112118 '<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
113119 '<h2 style="color:#c23934">Authentication failed</h2>' +
114120 '<p>Invalid state parameter — possible CSRF attack. Please try again.</p>' +
115121 '</body></html>'
116122 ) ;
117123 server . close ( ) ;
124+ server . closeAllConnections ?.( ) ;
118125 reject ( new Error ( 'OAuth callback state mismatch — possible CSRF. Try again.' ) ) ;
119126 return ;
120127 }
121128
122- res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
129+ // 'Connection: close' tells the browser to close the TCP connection after
130+ // this response so server.close() has no lingering keep-alive sockets to
131+ // wait for, allowing the Node.js event loop to exit promptly.
132+ res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' , Connection : 'close' } ) ;
123133 res . end (
124134 '<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
125135 '<h2 style="color:#0070d2">Authentication complete</h2>' +
126136 '<p>You can close this tab and return to the terminal.</p>' +
127137 '</body></html>'
128138 ) ;
129139 server . close ( ) ;
140+ // Destroy any sockets that are still open (e.g. a browser that ignores
141+ // the Connection:close header). Requires Node 18.2+.
142+ server . closeAllConnections ?.( ) ;
130143
131144 if ( code ) {
132145 resolve ( code ) ;
0 commit comments