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 , type ChildProcess } from 'node:child_process' ;
1313import { URL } from 'node:url' ;
1414
1515// All three ports must be pre-registered in the Cognito App Client.
@@ -76,21 +76,35 @@ function isPortFree(port: number): Promise<boolean> {
7676 * Open a URL in the system browser. The URL is passed as an argument — not
7777 * interpolated into a shell string — to avoid command injection.
7878 */
79- export function openBrowser ( url : string ) : void {
80- switch ( process . platform ) {
79+ /**
80+ * Return the platform-specific command and argument list for opening a URL
81+ * in the system browser. Exported so tests can assert the correct command is
82+ * chosen for each platform without actually spawning a process.
83+ */
84+ export function getBrowserCommand ( url : string , platform : NodeJS . Platform = process . platform ) : { cmd : string ; args : string [ ] } {
85+ switch ( platform ) {
8186 case 'darwin' :
82- execFile ( 'open' , [ url ] ) ;
83- break ;
87+ return { cmd : 'open' , args : [ url ] } ;
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 ] ) ;
88- break ;
91+ return { cmd : 'powershell.exe' , args : [ '-NoProfile' , '-Command' , 'Start-Process $args[0]' , '-args' , url ] } ;
8992 default :
90- execFile ( 'xdg-open' , [ url ] ) ;
93+ return { cmd : 'xdg-open' , args : [ url ] } ;
9194 }
9295}
9396
97+ export function openBrowser ( url : string ) : void {
98+ // detached:true + stdio:'ignore' + unref() is the standard Node.js pattern for
99+ // fire-and-forget child processes — the event loop will not wait for them to exit.
100+ const { cmd, args } = getBrowserCommand ( url ) ;
101+ const child : ChildProcess = spawn ( cmd , args , { detached : true , stdio : 'ignore' } ) ;
102+ // Suppress unhandled-error crashes if the browser executable is not found.
103+ // The login URL is already printed to the terminal so the user can open it manually.
104+ child . on ( 'error' , ( ) => { /* intentional no-op */ } ) ;
105+ child . unref ( ) ;
106+ }
107+
94108// ── Localhost callback server ─────────────────────────────────────────────────
95109
96110/**
@@ -107,26 +121,33 @@ export function listenForCallback(port: number, expectedState?: string): Promise
107121 const callbackState = parsed . searchParams . get ( 'state' ) ;
108122
109123 if ( expectedState && callbackState !== expectedState ) {
110- res . writeHead ( 400 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
124+ res . writeHead ( 400 , { 'Content-Type' : 'text/html; charset=utf-8' , Connection : 'close' } ) ;
111125 res . end (
112126 '<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
113127 '<h2 style="color:#c23934">Authentication failed</h2>' +
114128 '<p>Invalid state parameter — possible CSRF attack. Please try again.</p>' +
115129 '</body></html>'
116130 ) ;
117131 server . close ( ) ;
132+ server . closeAllConnections ?.( ) ;
118133 reject ( new Error ( 'OAuth callback state mismatch — possible CSRF. Try again.' ) ) ;
119134 return ;
120135 }
121136
122- res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
137+ // 'Connection: close' tells the browser to close the TCP connection after
138+ // this response so server.close() has no lingering keep-alive sockets to
139+ // wait for, allowing the Node.js event loop to exit promptly.
140+ res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' , Connection : 'close' } ) ;
123141 res . end (
124142 '<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
125143 '<h2 style="color:#0070d2">Authentication complete</h2>' +
126144 '<p>You can close this tab and return to the terminal.</p>' +
127145 '</body></html>'
128146 ) ;
129147 server . close ( ) ;
148+ // Destroy any sockets that are still open (e.g. a browser that ignores
149+ // the Connection:close header). Requires Node 18.2+.
150+ server . closeAllConnections ?.( ) ;
130151
131152 if ( code ) {
132153 resolve ( code ) ;
0 commit comments