Merge pull request #1829 from balena-io/1404-1710-web-login

Fix 'balena login' web authorization hanging with Google Chrome
This commit is contained in:
Paulo Castro 2020-05-18 11:13:03 +01:00 committed by GitHub
commit e50d92727e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 83 additions and 93 deletions

View File

@ -58,12 +58,10 @@ async function checkBuildTimestamps() {
This probably means that \`npm run build\` or \`npm test\` have not been executed,
and this error can be fixed by doing so. Running \`npm run build\` or \`npm test\`
before commiting is currently a requirement (documented in the CONTRIBUTING.md
file) for three reasons:
1. To update the CLI markdown documentation (in case any command-line options
were updated, added or removed).
2. To catch Typescript type check errors sooner and reduce overall waiting time,
given that balena-cli CI builds/tests are currently rather lengthy.
before commiting is required in order to update the CLI markdown documentation
(in case any command-line options were updated, added or removed) and also to
catch Typescript type check errors sooner and reduce overall waiting time, given
that the CI build/tests are currently rather lengthy.
If you need/wish to bypass this check without running \`npm run build\`, run:
npx touch -am "${docFile}"

View File

@ -1712,10 +1712,11 @@ how frequently (in minutes) to poll for application updates
## preload <image>
Preload a balena application release (app images/containers), and optionally
a balenaOS splash screen, in a previously downloaded balenaOS image file (or
Edison zip archive) in the local disk. The balenaOS image file can then be
flashed to a device's SD card. When the device boots, it will not need to
download the application, as it was preloaded.
a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file
in the local disk (a zip file is only accepted for the Intel Edison device type).
After preloading, the balenaOS image file can be flashed to a device's SD card.
When the device boots, it will not need to download the application, as it was
preloaded.
Warning: "balena preload" requires Docker to be correctly installed in
your shell environment. For more information (including Windows support)

View File

@ -1,5 +1,5 @@
/*
Copyright 2016-2017 Balena
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -110,7 +110,6 @@ Examples:
} else if (loginOptions.credentials) {
return patterns.authenticate(loginOptions);
} else if (loginOptions.web) {
console.info('Connecting to the web dashboard');
const auth = await import('../auth');
await auth.login();
return;
@ -143,6 +142,11 @@ Find out about the available commands by running:
$ balena help
${messages.reachingOut}`);
if (options.web) {
const { shutdownServer } = await import('../auth');
shutdownServer();
}
},
};

View File

@ -239,10 +239,11 @@ export const preload = {
description: 'preload an app on a disk image (or Edison zip archive)',
help: `\
Preload a balena application release (app images/containers), and optionally
a balenaOS splash screen, in a previously downloaded balenaOS image file (or
Edison zip archive) in the local disk. The balenaOS image file can then be
flashed to a device's SD card. When the device boots, it will not need to
download the application, as it was preloaded.
a balenaOS splash screen, in a previously downloaded '.img' balenaOS image file
in the local disk (a zip file is only accepted for the Intel Edison device type).
After preloading, the balenaOS image file can be flashed to a device's SD card.
When the device boots, it will not need to download the application, as it was
preloaded.
Warning: "balena preload" requires Docker to be correctly installed in
your shell environment. For more information (including Windows support)

View File

@ -1,5 +1,5 @@
/*
Copyright 2016 Balena
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,6 +15,9 @@ limitations under the License.
*/
import { getBalenaSdk } from '../utils/lazy';
import { awaitForToken, shutdownServer } from './server';
export { shutdownServer };
/**
* @module auth
@ -52,6 +55,7 @@ export const login = async () => {
// from mixed content warnings (as the target of a form in the result page)
const callbackUrl = `http://127.0.0.1:${options.port}${options.path}`;
const loginUrl = await utils.getDashboardLoginURL(callbackUrl);
console.info(`Opening web browser for URL:\n${loginUrl}`);
// Leave a bit of time for the
// server to get up and runing
setTimeout(async () => {
@ -59,7 +63,6 @@ export const login = async () => {
open(loginUrl, { wait: false });
}, 1000);
const server = await import('./server');
const balena = getBalenaSdk();
return server.awaitForToken(options).tap(balena.auth.loginWithToken);
return awaitForToken(options).tap(balena.auth.loginWithToken);
};

View File

@ -12,7 +12,8 @@
<div class="center">
<img class="icon" src="./static/images/sad.png" inline>
<h1>Something went wrong</h1>
<p>You couldn't login to the balena CLI for some reason</p>
<br>
<p>The balena CLI login was not successful.</p>
<br>
<br>
<a href="https://forums.balena.io/" class="button danger">Get help in our forums</a>

View File

@ -12,10 +12,9 @@
<div class="center">
<img class="icon" src="./static/images/happy.png" inline>
<h1>Success!</h1>
<p>You successfully logged in the balena CLI</p>
<br>
<br>
<a href="<%= dashboardUrl %>" class="button normal">Go to the dashboard</a>
<p>The balena CLI login was successful.</p>
<p>You may now close this page and return to the command prompt.</p>
</div>
</body>
</html>

View File

@ -1,5 +1,5 @@
/*
Copyright 2016 Balena
Copyright 2016-2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,10 +17,13 @@ limitations under the License.
import * as Promise from 'bluebird';
import * as bodyParser from 'body-parser';
import * as express from 'express';
import { Socket } from 'net';
import * as path from 'path';
import { getBalenaSdk } from '../utils/lazy';
import * as utils from './utils';
const serverSockets: Socket[] = [];
const createServer = ({ port }: { port: number }) => {
const app = express();
app.use(
@ -33,10 +36,29 @@ const createServer = ({ port }: { port: number }) => {
app.set('views', path.join(__dirname, 'pages'));
const server = app.listen(port);
server.on('connection', socket => serverSockets.push(socket));
return { app, server };
};
/**
* By design (more like a bug, but they won't admit it), a Node.js `http.server`
* instance prevents the process from exiting for up to 2 minutes (by default) if a
* client keeps a HTTP connection open, and regardless of whether `server.close()`
* was called: the `server.close(callback)` callback takes just as long to be called.
* Setting `server.timeout` to some value like 3 seconds works, but then the CLI
* process hangs for "only" 3 seconds (not good enough). Reducing the timeout to 1
* second may cause authentication failure if the laptop or CI server are slow for
* any reason. The only reliable way around it seems to be to explicitly unref the
* sockets, so the event loop stops waiting for it. See:
* https://github.com/nodejs/node/issues/2642
* https://github.com/nodejs/node-v0.x-archive/issues/9066
*/
export function shutdownServer() {
serverSockets.forEach(s => s.unref());
serverSockets.splice(0);
}
/**
* @summary Await for token
* @function
@ -60,45 +82,10 @@ export const awaitForToken = (options: {
const { app, server } = createServer({ port: options.port });
return new Promise<string>((resolve, reject) => {
const closeServer = (
errorMessage: string | undefined,
successPayload?: string,
) => {
server.close(() => {
if (errorMessage) {
reject(new Error(errorMessage));
return;
}
resolve(successPayload);
});
};
const renderAndDone = async ({
request,
response,
viewName,
errorMessage,
statusCode = 200,
token,
}: {
request: express.Request;
response: express.Response;
viewName: 'success' | 'error';
errorMessage?: string;
statusCode?: number;
token?: string;
}) => {
const context = await getContext(viewName);
response.status(statusCode).render(viewName, context);
request.connection.destroy();
closeServer(errorMessage, token);
};
app.post(options.path, async (request, response) => {
server.close(); // stop listening for new connections
try {
const token = request.body.token?.trim();
if (!token) {
throw new Error('No token');
}
@ -106,31 +93,18 @@ export const awaitForToken = (options: {
if (!loggedIn) {
throw new Error('Invalid token');
}
await renderAndDone({ request, response, viewName: 'success', token });
response.status(200).render('success');
resolve(token);
} catch (error) {
await renderAndDone({
request,
response,
viewName: 'error',
statusCode: 401,
errorMessage: error.message,
});
response.status(401).render('error');
reject(new Error(error.message));
}
});
app.use((_request, response) => {
server.close(); // stop listening for new connections
response.status(404).send('Not found');
closeServer('Unknown path or verb');
reject(new Error('Unknown path or verb'));
});
});
};
export const getContext = (viewName: 'success' | 'error') => {
if (viewName === 'success') {
return Promise.props({
dashboardUrl: getBalenaSdk().settings.get('dashboardUrl'),
});
}
return Promise.resolve({});
};

View File

@ -1,4 +1,20 @@
import * as Promise from 'bluebird';
/**
* @license
* Copyright 2019-2020 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Bluebird from 'bluebird';
import * as chai from 'chai';
import chaiAsPromised = require('chai-as-promised');
import * as ejs from 'ejs';
@ -7,12 +23,7 @@ import * as path from 'path';
import * as request from 'request';
import * as sinon from 'sinon';
// TODO: Convert server code to Typescript so it can have a declaration file
// @ts-ignore
import * as server from '../../build/auth/server';
// TODO: Convert utils code to Typescript so it can have a declaration file
// @ts-ignore
import * as utils from '../../build/auth/utils';
import tokens from './tokens';
@ -25,9 +36,7 @@ const options = {
path: '/auth',
};
const getPage = function(
name: Parameters<typeof server.getContext>[0],
): Promise<string> {
async function getPage(name: string): Promise<string> {
const pagePath = path.join(
__dirname,
'..',
@ -39,8 +48,8 @@ const getPage = function(
);
const tpl = fs.readFileSync(pagePath, { encoding: 'utf8' });
const compiledTpl = ejs.compile(tpl);
return server.getContext(name).then((context: any) => compiledTpl(context));
};
return compiledTpl();
}
describe('Server:', function() {
it('should get 404 if posting to an unknown path', function(done) {
@ -86,7 +95,7 @@ describe('Server:', function() {
describe('given the token authenticates with the server', function() {
beforeEach(function() {
this.loginIfTokenValidStub = sinon.stub(utils, 'loginIfTokenValid');
return this.loginIfTokenValidStub.returns(Promise.resolve(true));
return this.loginIfTokenValidStub.returns(Bluebird.resolve(true));
});
afterEach(function() {
@ -119,7 +128,7 @@ describe('Server:', function() {
return describe('given the token does not authenticate with the server', function() {
beforeEach(function() {
this.loginIfTokenValidStub = sinon.stub(utils, 'loginIfTokenValid');
return this.loginIfTokenValidStub.returns(Promise.resolve(false));
return this.loginIfTokenValidStub.returns(Bluebird.resolve(false));
});
afterEach(function() {