@@ -25,6 +25,26 @@ const GENERIC_OAUTH_CLIENT_ID = 'udz8zp4yue87uk9dzq4xk425kkwvqvh1';
2525const GENERIC_OAUTH_CLIENT_SECRET = 'iZ1MbvC3ZaF25nbJli7IsKdRHAxfu3fn' ;
2626const SUPPORTED_DEFAULT_APP_PORTS = [ 3000 , 3001 , 4000 , 5000 , 8080 ] ;
2727const DEFAULT_ENVIRONMENT_NAME = 'oauth' ;
28+ const OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 ;
29+ const LOOPBACK_HOST = 'localhost' ;
30+ const DEFAULT_OPEN_AUTHORIZE_IN_BROWSER = (
31+ openFn ,
32+ apps ,
33+ authorizeUrl ,
34+ useIncognito
35+ ) => {
36+ if ( useIncognito ) {
37+ openFn ( authorizeUrl , {
38+ newInstance : true ,
39+ app : { name : apps . browserPrivate } ,
40+ } ) ;
41+ } else {
42+ openFn ( authorizeUrl ) ;
43+ }
44+ } ;
45+
46+ let oauthCallbackTimeoutMs = OAUTH_CALLBACK_TIMEOUT_MS ;
47+ let openAuthorizeInBrowser = DEFAULT_OPEN_AUTHORIZE_IN_BROWSER ;
2848
2949async function promptForPlatformAppCredentials ( inquirerModule , clientId ) {
3050 if ( ! clientId ) {
@@ -111,7 +131,7 @@ class OAuthLoginCommand extends BoxCommand {
111131 let useDefaultBoxApp = false ;
112132 const environmentsObject = await this . getEnvironments ( ) ;
113133 const port = flags . port ;
114- const redirectUri = `http://localhost :${ port } /callback` ;
134+ const redirectUri = `http://${ LOOPBACK_HOST } :${ port } /callback` ;
115135 const isUnsupportedDefaultAppPort = ( ) =>
116136 useDefaultBoxApp && ! SUPPORTED_DEFAULT_APP_PORTS . includes ( port ) ;
117137 let environment ;
@@ -157,13 +177,6 @@ class OAuthLoginCommand extends BoxCommand {
157177 useDefaultBoxApp = forceDefaultBoxApp ;
158178 }
159179
160- if ( isUnsupportedDefaultAppPort ( ) ) {
161- this . info (
162- chalk `{red Unsupported port "${ port } " for the Official Box CLI app flow. Supported ports: ${ SUPPORTED_DEFAULT_APP_PORTS . join ( ', ' ) } }`
163- ) ;
164- return ;
165- }
166-
167180 if ( this . flags . reauthorize ) {
168181 // Keep the selected existing environment config for reauthorization.
169182 } else if ( useDefaultBoxApp ) {
@@ -205,13 +218,6 @@ class OAuthLoginCommand extends BoxCommand {
205218 }
206219 useDefaultBoxApp = answers . useDefaultBoxApp ;
207220
208- if ( isUnsupportedDefaultAppPort ( ) ) {
209- this . info (
210- chalk `{red Unsupported port "${ port } " for the Official Box CLI app flow. Supported ports: ${ SUPPORTED_DEFAULT_APP_PORTS . join ( ', ' ) } }`
211- ) ;
212- return ;
213- }
214-
215221 environment = {
216222 clientId : answers . clientId ,
217223 clientSecret : answers . clientSecret ,
@@ -221,6 +227,13 @@ class OAuthLoginCommand extends BoxCommand {
221227 } ;
222228 }
223229
230+ if ( isUnsupportedDefaultAppPort ( ) ) {
231+ this . info (
232+ chalk `{red Unsupported port "${ port } " for the Official Box CLI app flow. Supported ports: ${ SUPPORTED_DEFAULT_APP_PORTS . join ( ', ' ) } }`
233+ ) ;
234+ return ;
235+ }
236+
224237 const environmentName = environment . name ;
225238 const sdkConfig = Object . freeze ( {
226239 analyticsClient : {
@@ -234,14 +247,89 @@ class OAuthLoginCommand extends BoxCommand {
234247 this . _configureSdk ( sdk , sdkConfig ) ;
235248
236249 const app = express ( ) ;
250+ let callbackHandled = false ;
251+
252+ // Keep run() blocked until callback flow completes.
253+ // This prevents command exit before the OAuth redirect returns.
254+ let resolveCallbackFlow ;
255+ const callbackFlowDone = new Promise ( ( resolve ) => {
256+ resolveCallbackFlow = resolve ;
257+ } ) ;
258+ let callbackTimeout ;
259+
260+ // Timeout and callback may race, so teardown must be idempotent.
261+ // This guard ensures cleanup and resolve happen only once.
262+ let callbackFlowResolved = false ;
263+
237264 let server ;
265+ try {
266+ // Bind only to loopback to avoid exposing callback externally.
267+ // Browser redirect is local, so external interfaces are unnecessary.
268+ server = await new Promise ( ( resolve , reject ) => {
269+ const s = app . listen ( port , LOOPBACK_HOST ) ;
270+ s . once ( 'listening' , ( ) => resolve ( s ) ) ;
271+ s . once ( 'error' , reject ) ;
272+ } ) ;
273+ } catch ( error ) {
274+ if ( error . code === 'EADDRINUSE' ) {
275+ throw new BoxCLIError (
276+ `Port ${ port } is already in use. Please close the application using this port or use --port to specify a different port.` ,
277+ error
278+ ) ;
279+ }
280+ throw new BoxCLIError (
281+ `Failed to start local OAuth server on port ${ port } : ${ error . message } ` ,
282+ error
283+ ) ;
284+ }
238285
239- server = app . listen ( port ) ;
286+ const shutdownServer = ( ) => {
287+ if ( ! server ) {
288+ return ;
289+ }
290+ server . close ( ) ;
291+ if ( typeof server . closeAllConnections === 'function' ) {
292+ server . closeAllConnections ( ) ;
293+ }
294+ } ;
295+
296+ // Use one finalize path so all exits apply the same cleanup.
297+ // This keeps timeout and callback completion behavior consistent.
298+ const finalizeCallbackFlow = ( ) => {
299+ if ( callbackFlowResolved ) {
300+ return ;
301+ }
302+ callbackFlowResolved = true ;
303+ clearTimeout ( callbackTimeout ) ;
304+ shutdownServer ( ) ;
305+ resolveCallbackFlow ( ) ;
306+ } ;
307+
308+ // Bound callback wait time to avoid hanging sessions forever.
309+ // If user abandons auth, the command exits predictably.
310+ callbackTimeout = setTimeout ( ( ) => {
311+ if ( callbackHandled ) {
312+ return ;
313+ }
314+ this . info (
315+ chalk `{red Login timed out waiting for OAuth callback after ${ oauthCallbackTimeoutMs / 1000 } seconds.}`
316+ ) ;
317+ finalizeCallbackFlow ( ) ;
318+ } , oauthCallbackTimeoutMs ) ;
240319
241320 const state = nanoid ( 32 ) ;
242321 const pkce = useDefaultBoxApp ? generatePKCE ( ) : null ;
243322
244323 app . get ( '/callback' , async ( request , res ) => {
324+ // Reject replayed callbacks after a completion was already accepted.
325+ // This enforces single-use semantics for the local callback endpoint.
326+ if ( callbackHandled ) {
327+ res . status ( 409 ) . send ( 'OAuth callback already handled.' ) ;
328+ return ;
329+ }
330+
331+ callbackHandled = true ;
332+
245333 try {
246334 if ( request . query . state !== state ) {
247335 throw new BoxCLIError (
@@ -266,7 +354,6 @@ class OAuthLoginCommand extends BoxCommand {
266354 } ) ;
267355 } ) ;
268356 const client = sdk . getPersistentClient ( tokenInfo , tokenCache ) ;
269-
270357 const user = await client . users . get ( 'me' ) ;
271358
272359 environmentsObject . environments [ environmentName ] = environment ;
@@ -309,9 +396,10 @@ class OAuthLoginCommand extends BoxCommand {
309396 'Unknown error' ;
310397 DEBUG . execute ( 'Login error: %O' , error ) ;
311398 this . info ( chalk `{red Login failed: ${ errorMessage } }` ) ;
399+ res . status ( 500 ) . send ( 'Login failed. Please check the CLI output for details.' ) ;
400+
312401 } finally {
313- server . close ( ) ;
314- server . closeAllConnections ( ) ;
402+ finalizeCallbackFlow ( ) ;
315403 }
316404 } ) ;
317405
@@ -355,24 +443,22 @@ class OAuthLoginCommand extends BoxCommand {
355443 } ,
356444 ] ) ;
357445 http . get (
358- `http://localhost :${ port } /callback?state=${ authInfo . state } &code=${ authInfo . code } `
446+ `http://${ LOOPBACK_HOST } :${ port } /callback?state=${ authInfo . state } &code=${ authInfo . code } `
359447 ) ;
360448 } else {
361- if ( flags [ 'incognito-browser' ] ) {
362- open ( authorizeUrl , {
363- newInstance : true ,
364- app : { name : apps . browserPrivate } ,
365- } ) ;
366- } else {
367- open ( authorizeUrl ) ;
368- }
449+ openAuthorizeInBrowser (
450+ open ,
451+ apps ,
452+ authorizeUrl ,
453+ flags [ 'incognito-browser' ]
454+ ) ;
369455 this . info (
370456 useDefaultBoxApp
371457 ? chalk `{yellow If authorization fails, verify that you are using one of the supported ports for the Official Box CLI app flow and restart the login command.}`
372458 : chalk `{yellow If you are redirected to the Files view, make sure your Redirect URI is configured correctly and restart the login command.}`
373459 ) ;
374460 }
375- await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) ) ;
461+ await callbackFlowDone ;
376462 }
377463}
378464
@@ -443,4 +529,16 @@ module.exports = OAuthLoginCommand;
443529module . exports . _test = {
444530 promptForAuthMethod,
445531 promptForPlatformAppCredentials,
532+ setOAuthCallbackTimeoutMs ( timeoutMs ) {
533+ oauthCallbackTimeoutMs = timeoutMs ;
534+ } ,
535+ resetOAuthCallbackTimeoutMs ( ) {
536+ oauthCallbackTimeoutMs = OAUTH_CALLBACK_TIMEOUT_MS ;
537+ } ,
538+ setOpenAuthorizeInBrowser ( fn ) {
539+ openAuthorizeInBrowser = fn ;
540+ } ,
541+ resetOpenAuthorizeInBrowser ( ) {
542+ openAuthorizeInBrowser = DEFAULT_OPEN_AUTHORIZE_IN_BROWSER ;
543+ } ,
446544} ;
0 commit comments