Skip to content
This repository has been archived by the owner on Jun 14, 2024. It is now read-only.

feat: authenticate without entering a password #513

Merged
merged 9 commits into from
Dec 21, 2020
5 changes: 4 additions & 1 deletion context/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
const ApplicationContext = require('./application-context');

module.exports = new ApplicationContext();
/** @type {import('./application-context')<import('./init').Context>} */
const context = new ApplicationContext();

module.exports = context;
61 changes: 56 additions & 5 deletions context/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,29 @@ const chalk = require('chalk');
const fs = require('fs');
const os = require('os');
const inquirer = require('inquirer');
const open = require('open');
const openIdClient = require('openid-client');
const superagent = require('superagent');
const { promisify } = require('util');
const Database = require('../services/database');
const pkg = require('../package.json');
const applicationTokenDeserializer = require('../deserializers/application-token');
const applicationTokenSerializer = require('../serializers/application-token');
const Api = require('../services/api');
const ApplicationTokenService = require('../services/application-token');
const logger = require('../services/logger');
const terminator = require('../utils/terminator');
const Api = require('../services/api');
const Authenticator = require('../services/authenticator');
const authenticatorHelper = require('../utils/authenticator-helper');
const OidcAuthenticator = require('../services/oidc/authenticator');
const ErrorHandler = require('../services/error-handler');
const messages = require('../utils/messages');

const fsAsync = {
readFile: promisify(fs.readFile),
stat: promisify(fs.stat),
unlink: promisify(fs.unlink),
};

/**
* @typedef {{
Expand All @@ -18,30 +35,45 @@ const authenticatorHelper = require('../utils/authenticator-helper');
*
* @typedef {{
* env: Env
* process: NodeJS.Process,
* pkg: import('../package.json'),
* }} EnvPart
*
* @typedef {{
* openIdClient: import('openid-client');
* chalk: import('chalk');
* open: import('open');
* fs: import('fs');
* os: import('os');
* chalk: import('chalk');
* inquirer: import('inquirer');
* mongodb: import('mongodb');
* Sequelize: import('sequelize');
* superagent: import('superagent');
* fsAsync: fsAsync;
* }} Dependencies
*
* @typedef {{
* terminator: import('../utils/terminator');
* authenticatorHelper: import('../utils/authenticator-helper');
* messages: import('../utils/messages');
* }} Utils
*
* @typedef {{
* applicationTokenSerializer: import('../serializers/application-token');
* applicationTokenDeserializer: import('../deserializers/application-token');
* }} Serializers
*
* @typedef {{
* logger: import('../services/logger');
* database: import('../services/database');
* api: import('../services/api');
* authenticator: import('../services/authenticator');
* oidcAuthenticator: import('../services/oidc/authenticator');
* errorHandler: import('../services/error-handler');
* applicationTokenService: import('../services/application-token');
* }} Services
*
* @typedef {EnvPart & Dependencies & Utils & Services} Context
* @typedef {EnvPart & Dependencies & Utils & Serializers & Services} Context
*/

/**
Expand All @@ -50,38 +82,56 @@ const authenticatorHelper = require('../utils/authenticator-helper');
function initEnv(context) {
context.addInstance('env', {
...process.env,
FOREST_URL: process.env.FOREST_URL || 'https://app.forestadmin.com',
FOREST_URL: process.env.FOREST_URL || 'https://api.forestadmin.com',
});
context.addInstance('process', process);
context.addInstance('pkg', pkg);
}

/**
* @param {import('./application-context')} context
*/
function initDependencies(context) {
context.addInstance('openIdClient', openIdClient);
context.addInstance('chalk', chalk);
context.addInstance('open', open);
context.addInstance('fs', fs);
context.addInstance('os', os);
context.addInstance('chalk', chalk);
context.addInstance('inquirer', inquirer);
context.addInstance('Sequelize', Sequelize);
context.addInstance('mongodb', mongodb);
context.addInstance('superagent', superagent);
context.addInstance('fsAsync', fsAsync);
}

/**
* @param {import('./application-context')} context
*/
function initUtils(context) {
context.addInstance('terminator', terminator);
context.addInstance('messages', messages);
context.addInstance('authenticatorHelper', authenticatorHelper);
}

/**
* @param {import('./application-context')} context
*/
function initSerializers(context) {
context.addInstance('applicationTokenSerializer', applicationTokenSerializer);
context.addInstance('applicationTokenDeserializer', applicationTokenDeserializer);
}

/**
* @param {import('./application-context')} context
*/
function initServices(context) {
context.addInstance('logger', logger);
context.addClass(Database);
context.addClass(Api);
context.addClass(ApplicationTokenService);
context.addClass(Authenticator);
context.addClass(OidcAuthenticator);
context.addClass(ErrorHandler);
}

/**
Expand All @@ -92,6 +142,7 @@ function initContext(context) {
initEnv(context);
initDependencies(context);
initUtils(context);
initSerializers(context);
initServices(context);

return context;
Expand Down
15 changes: 15 additions & 0 deletions deserializers/application-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const JSONAPIDeserializer = require('jsonapi-serializer').Deserializer;

/**
* @typedef {{
* id: string;
* name: string;
* token: string;
* }} ApplicationToken
*/

const applicationTokenDeserializer = new JSONAPIDeserializer({
keyForAttribute: 'camelCase',
});

module.exports = applicationTokenDeserializer;
46 changes: 27 additions & 19 deletions lumber-login.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ const program = require('commander');
const inquirer = require('inquirer');
const context = require('./context');
const initContext = require('./context/init');
const { EMAIL_REGEX } = require('./utils/regexs');

initContext(context);

const { EMAIL_REGEX } = require('./utils/regexs');

const { logger, authenticator } = context.inject();
const {
logger, authenticator, oidcAuthenticator, errorHandler, applicationTokenService,
} = context.inject();

if (!logger) throw new Error('Missing dependency logger');
if (!authenticator) throw new Error('Missing dependency authenticator');
if (!errorHandler) throw new Error('Missing dependency errorHandler');
if (!applicationTokenService) throw new Error('Missing dependency applicationTokenService');

program
.description('Log into Forest Admin API')
Expand All @@ -20,26 +23,31 @@ program
.parse(process.argv);

(async () => {
let { email } = program;

if (!email) {
({ email } = await inquirer.prompt([{
type: 'input',
name: 'email',
message: 'What\'s your email address?',
validate: (input) => {
if (EMAIL_REGEX.test(input)) { return true; }
return input ? 'Invalid email' : 'Please enter your email address.';
},
}]));
let { email, token } = program;
const { password } = program;

if (!token && !password) {
const sessionToken = await oidcAuthenticator.authenticate();
token = await applicationTokenService.generateApplicationToken(sessionToken);
} else {
if (!email) {
({ email } = await inquirer.prompt([{
type: 'input',
name: 'email',
message: 'What\'s your email address?',
validate: (input) => {
if (EMAIL_REGEX.test(input)) { return true; }
return input ? 'Invalid email' : 'Please enter your email address.';
},
}]));
}

token = await authenticator.loginWithEmailOrTokenArgv({ ...program, email });
}

const token = await authenticator.loginWithEmailOrTokenArgv({ ...program, email });
authenticator.saveToken(token);

logger.success('Login successful');
process.exit(0);
})().catch(async (error) => {
logger.error(error);
process.exit(1);
await errorHandler.handle(error);
});
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@
"jsonapi-serializer": "^3.4.1",
"lodash": "4.17.19",
"mkdirp": "^0.5.1",
"mongodb": "3.6.3",
"mysql2": "2.2.5",
"mongodb": "3.3.0",
"mysql2": "2.1.0",
"open": "^7.3.0",
"openid-client": "^4.2.1",
"pg": "8.2.1",
"pluralize": "^8.0.0",
"saslprep": "1.0.3",
Expand Down
11 changes: 11 additions & 0 deletions serializers/application-token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const JSONAPISerializer = require('jsonapi-serializer').Serializer;

/**
* @typedef {{ name: string }} InputApplicationToken
*/

const applicationTokenSerializer = new JSONAPISerializer('application-tokens', {
attributes: ['name'],
});

module.exports = applicationTokenSerializer;
69 changes: 58 additions & 11 deletions services/api.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
const agent = require('superagent');
const UserSerializer = require('../serializers/user');
const UserDeserializer = require('../deserializers/user');
const ProjectSerializer = require('../serializers/project');
const ProjectDeserializer = require('../deserializers/project');
const EnvironmentSerializer = require('../serializers/environment');
const EnvironmentDeserializer = require('../deserializers/environment');
const pkg = require('../package.json');

const HEADER_CONTENT_TYPE = 'Content-Type';
const HEADER_CONTENT_TYPE_JSON = 'application/json';
const HEADER_FOREST_ORIGIN = 'forest-origin';
const HEADER_USER_AGENT = 'User-Agent';

class Api {
constructor() {
this.endpoint = process.env.FOREST_URL || 'https://api.forestadmin.com';
this.userAgent = `lumber@${pkg.version}`;
/**
* @param {import('../context/init').Context} context
*/
constructor(context) {
this.applicationTokenSerializer = context.applicationTokenSerializer;
this.applicationTokenDeserializer = context.applicationTokenDeserializer;
this.agent = context.superagent;
this.env = context.env;
this.pkg = context.pkg;

['applicationTokenSerializer',
'applicationTokenDeserializer',
'agent',
'env',
'pkg',
].forEach((name) => {
if (!this[name]) throw new Error(`Missing dependency ${name}`);
});

this.endpoint = this.env.FOREST_URL || 'https://api.forestadmin.com';
this.userAgent = `lumber@${this.pkg.version}`;
}

async isGoogleAccount(email) {
return agent
return this.agent
.get(`${this.endpoint}/api/users/google/${email}`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
Expand All @@ -30,7 +46,7 @@ class Api {
}

async login(email, password) {
return agent
return this.agent
.post(`${this.endpoint}/api/sessions`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
Expand All @@ -40,7 +56,7 @@ class Api {
}

async createUser(user) {
return agent
return this.agent
.post(`${this.endpoint}/api/users`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
Expand All @@ -53,7 +69,7 @@ class Api {
let newProject;

try {
newProject = await agent
newProject = await this.agent
.post(`${this.endpoint}/api/projects`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
Expand All @@ -69,7 +85,7 @@ class Api {
throw error;
}

newProject = await agent
newProject = await this.agent
.get(`${this.endpoint}/api/projects/${projectId}`)
.set('Authorization', `Bearer ${sessionToken}`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
Expand All @@ -88,7 +104,7 @@ class Api {
const port = config.appPort || 3310;
const protocol = hostname.startsWith('http') ? '' : 'http://';
newProject.defaultEnvironment.apiEndpoint = `${protocol}${hostname}:${port}`;
const updatedEnvironment = await agent
const updatedEnvironment = await this.agent
.put(`${this.endpoint}/api/environments/${newProject.defaultEnvironment.id}`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
Expand All @@ -101,6 +117,37 @@ class Api {

return newProject;
}

/**
* @param {import('../serializers/application-token').InputApplicationToken} applicationToken
* @param {string} sessionToken
* @returns {Promise<import('../deserializers/application-token').ApplicationToken>}
*/
async createApplicationToken(applicationToken, sessionToken) {
return this.agent
.post(`${this.endpoint}/api/application-tokens`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
.set(HEADER_USER_AGENT, this.userAgent)
.set('Authorization', `Bearer ${sessionToken}`)
.send(this.applicationTokenSerializer.serialize(applicationToken))
.then((response) => this.applicationTokenDeserializer.deserialize(response.body));
}

/**
* @param {import('../serializers/application-token').InputApplicationToken} applicationToken
* @param {string} sessionToken
* @returns {Promise<import('../deserializers/application-token').ApplicationToken>}
*/
async deleteApplicationToken(applicationToken) {
return this.agent
.delete(`${this.endpoint}/api/application-tokens`)
.set(HEADER_FOREST_ORIGIN, 'Lumber')
.set(HEADER_CONTENT_TYPE, HEADER_CONTENT_TYPE_JSON)
.set(HEADER_USER_AGENT, this.userAgent)
.set('Authorization', `Bearer ${applicationToken}`)
.send();
}
}

module.exports = Api;
Loading