Skip to content

Commit 307d181

Browse files
authored
feat: Secure OAuth callback handling (#653)
1 parent 63959c6 commit 307d181

2 files changed

Lines changed: 282 additions & 29 deletions

File tree

src/commands/login.js

Lines changed: 127 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,26 @@ const GENERIC_OAUTH_CLIENT_ID = 'udz8zp4yue87uk9dzq4xk425kkwvqvh1';
2525
const GENERIC_OAUTH_CLIENT_SECRET = 'iZ1MbvC3ZaF25nbJli7IsKdRHAxfu3fn';
2626
const SUPPORTED_DEFAULT_APP_PORTS = [3000, 3001, 4000, 5000, 8080];
2727
const 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

2949
async 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;
443529
module.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

Comments
 (0)