baqend
Version:
Baqend JavaScript SDK
426 lines (358 loc) • 12.9 kB
text/typescript
import open from 'open';
import { ChildProcess } from 'child_process';
import crypto from 'crypto';
import { createServer } from 'http';
import os from 'os';
import inquirer from 'inquirer';
import {
EntityManager, EntityManagerFactory, intersection, binding,
} from 'baqend';
import * as helper from './helper';
import {
isFile, readFile, readModuleFile,
} from './helper';
const { TokenStorage } = intersection;
const { UserFactory } = binding;
const fileName = `${os.homedir()}/.baqend`;
const algorithm = 'aes-256-ctr';
const PROFILE_DEFAULT_KEY = 'N2Ki=za[8iy4ff4jYn/3,y;';
const bbqHost = 'bbq';
export type AccountArgs = {
app?: string | null,
username?: string,
password?: string,
token?: string,
skipInput?: boolean,
auth?: string
};
export type AppInfo = {
isCustomHost: boolean,
host: string,
app: string | null,
};
export type UsernamePasswordCredentials = {
username: string,
password: string,
};
export type TokenCredentials = {
token: string,
};
export type Credentials = UsernamePasswordCredentials | TokenCredentials;
type ProfileJson = {
[host: string]: {
host: string,
} & Credentials,
};
function getAppInfo(args: AccountArgs): AppInfo {
const isCustomHost = args.app && /^https?:\/\//.test(args.app);
return {
isCustomHost: !!isCustomHost,
host: isCustomHost ? args.app! : bbqHost,
app: isCustomHost ? null : args.app!,
};
}
function getArgsCredentials(args: AccountArgs): Credentials | null {
if (args.username && args.password) {
return {
username: args.username,
password: args.password,
};
}
if (args.token) {
return { token: args.token };
}
return null;
}
function getEnvCredentials(): Credentials | null {
const token = process.env.BAQEND_TOKEN || process.env.BAT;
if (token) {
return { token };
}
return null;
}
function getProfileCredentials(appInfo: AppInfo): Promise<Credentials | null> {
return readProfileFile().then((json) => {
const credentials = json[appInfo.host];
if (!credentials) {
return null;
}
if ('password' in credentials) {
console.log('Storing username/password in the baqend profile will not be supported in future version.');
console.log('Logout and login again to fix this issue.');
credentials.password = decrypt(credentials.password);
}
if ('token' in credentials) {
const { createdAt, expireAt } = TokenStorage.parse(credentials.token);
// validate token expiration
if (createdAt + (24 * 60 * 60 * 1000) < Date.now()) {
return null
}
}
return credentials;
}).catch(() => null);
}
function getLocalCredentials(appInfo: AppInfo): Credentials | null {
if (appInfo.isCustomHost) {
return { username: 'root', password: 'root' };
}
return null;
}
async function getInputCredentials(appInfo: AppInfo, authProvider?: string, showLoginInfo?: boolean):
Promise<Credentials> {
if (!process.stdout.isTTY) {
throw new Error('Can\'t interactive login into baqend, no tty session was detected.');
}
if (showLoginInfo) {
console.log('Baqend Login is required. You can skip this step by saving the Login credentials with "baqend login or baqend sso"');
}
const options = ['password', 'google', 'facebook', 'github'];
let result = authProvider || 'password';
if (!appInfo.isCustomHost && options.length > 1 && !authProvider) {
const responses = await inquirer.prompt([{
name: 'loginType',
message: 'Choose how you want to login:',
type: 'list',
default: 'google',
choices: options.map((op) => ({ name: op })),
}]);
result = responses.loginType as string;
}
if (!result) {
throw new Error('No valid login option was chosen.');
}
if (result === 'password') {
return readInputCredentials(appInfo);
}
return requestSSOCredentials(appInfo, result);
}
function requestSSOCredentials(appInfo: AppInfo, oAuthProvider: string, oAuthOptions: binding.OAuthOptions = {}):
Promise<TokenCredentials> {
// TODO: current workaround until our server pass this ids to the client dynamically
const clientIds: { [oAuthProvider: string]: string } = {
facebook: '976707865723719',
google: '586076830320-0el1jebupjvbcmqf95vfaqjq7gbs0bdh.apps.googleusercontent.com',
gitHub: '1311e3415ab415fda705',
};
return appConnect(appInfo)
.then((db) => {
const host = '127.0.0.1';
const port = 9876;
const provider = oAuthProvider.toLowerCase();
return Promise.all([
oAuthHandler(host, port),
db.loginWithOAuth(oAuthProvider, {
...(UserFactory.DefaultOptions as any)[provider] || {},
...(clientIds[provider] && { clientId: clientIds[provider] }),
...{ open },
...oAuthOptions,
redirect: `http://${host}:${port}`,
}) as Promise<ChildProcess>,
]).then(([credentials, windowProcess]) => {
windowProcess.kill('SIGHUP'); // seems not working on every platform
return credentials;
});
});
}
async function oAuthHandler(host: string, port: number): Promise<TokenCredentials> {
const htmlTemplate = await readModuleFile('./sso.html');
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url!, `http://${host}:${port}`);
const errorMessage = url.searchParams.get('errorMessage');
let text: string | null = null;
if (errorMessage) {
reject(new Error(errorMessage));
text = `<h1>An error has occurred</h1><p>${errorMessage}</p>`;
} else if (url.searchParams.has('token')) {
resolve({ token: url.searchParams.get('token')! });
text = '<h1>Continue within the CLI</h1><p>You can close this window now.</p>';
}
if (text) {
// eslint-disable-next-line no-template-curly-in-string
const html = htmlTemplate.replace('${content}', text);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
setTimeout(() => {
server.close();
});
} else {
res.writeHead(404);
res.end();
}
});
server.on('error', (err) => {
reject(err);
});
server.listen(port, host);
});
}
function getCredentials(appInfo: AppInfo, args: AccountArgs): Promise<Credentials | null> {
let providers = Promise.resolve(null)
.then((credentials) => credentials || getArgsCredentials(args))
.then((credentials) => credentials || getEnvCredentials())
.then((credentials) => credentials || getProfileCredentials(appInfo))
.then((credentials) => credentials || getLocalCredentials(appInfo));
if (!args.skipInput && process.stdout.isTTY) {
providers = providers
.then((credentials) => credentials || persistLogin({ ...appInfo, ...args }));
}
return providers;
}
export function register(args: AccountArgs) {
const appInfo = getAppInfo(args);
return getInputCredentials(appInfo, args.auth)
.then((credentials) => appConnect(appInfo)
.then((db) => {
if ('token' in credentials) {
return db.User.loginWithToken(credentials.token).then(() => db);
}
return db.User.register(credentials.username, credentials.password).then(() => db);
})
.then((db) => Promise.all([
getDefaultApp(db).then((name) => console.log(`Your app name is ${name}`)),
saveCredentials(appInfo, { token: db.token! }),
])));
}
function connect(args: AccountArgs) {
const appInfo = getAppInfo(args);
return getCredentials(appInfo, args)
.then((credentials) => {
if (!credentials) throw new Error('Login information are missing. Login with baqend login or pass a baqend token via BAQEND_TOKEN environment variable');
return appConnect(appInfo, credentials);
});
}
function appConnect(appInfo: AppInfo, credentials?: Credentials): Promise<EntityManager> {
// do not use the global token storage here, to prevent login collisions on the bbq app
const factory = new EntityManagerFactory({ host: appInfo.host, secure: true, tokenStorageFactory: TokenStorage });
return factory.createEntityManager(true).ready().then((db: EntityManager) => {
if (!credentials) return db;
if ('token' in credentials) {
return db.User.loginWithToken(credentials.token)
.then((me) => {
if (me) {
return db;
}
throw new Error('Login with Baqend token failed.');
});
}
return db.User.login(credentials.username, credentials.password).then(() => db);
});
}
export function login(args: AccountArgs): Promise<EntityManager> {
const appInfo = getAppInfo(args);
return connect(args)
.then((db) => {
if (appInfo.isCustomHost) {
return db;
}
if (appInfo.app) {
return bbqAppLogin(db, appInfo.app);
}
return getDefaultApp(db).then((appName) => bbqAppLogin(db, appName));
}).catch((e) => {
// if the login failed try to directly login into the app
if (appInfo.app && !appInfo.isCustomHost) {
return login({ ...args, app: `https://${appInfo.app}.app.baqend.com/v1` });
}
throw e;
});
}
async function bbqAppLogin(db: EntityManager, appName: string) {
const { token } = await db.modules.get('token', { app: appName });
return appConnect({ host: appName, isCustomHost: false, app: appName }, { token });
}
export function logout(args: AccountArgs) {
const appInfo = getAppInfo(args);
return readProfileFile().then((json) => {
// eslint-disable-next-line no-param-reassign
delete json[appInfo.host];
return writeProfileFile(json);
});
}
export async function persistLogin(args: AccountArgs) {
const appInfo = getAppInfo(args);
let credentials: Credentials | null = getArgsCredentials(args);
if (!credentials) {
credentials = await getInputCredentials(appInfo, args.auth, false);
}
const db = await appConnect(appInfo, credentials)
const tokenCredentials: TokenCredentials = 'token' in credentials ? credentials : { token: db.token! };
await saveCredentials(appInfo, tokenCredentials);
return tokenCredentials;
}
export function openApp(app: string) {
if (app) {
return open(`https://${app}.app.baqend.com`);
}
return login({}).then((db) => {
open(`https://${db.connection!.host}`);
});
}
export function openDashboard(args: AccountArgs) {
return connect(args).then((db) => {
open(`https://dashboard.baqend.com/login?token=${db.token}`);
}).catch(() => {
open('https://dashboard.baqend.com');
});
}
export async function listApps(args: AccountArgs) {
const db = await connect(args);
let apps = await getApps(db);
apps = apps.sort();
apps.forEach((app) => console.log(app));
}
export function whoami(args: AccountArgs) {
return connect({ skipInput: true, ...args })
.then((db) => console.log(db.User.me!.username), () => console.log('You are not logged in.'));
}
export async function getApps(db: EntityManager): Promise<string[]> {
let query = db.App.find()
.eq('status', 'running');
if (db.User.me?.username?.endsWith('@baqend.com')) {
query = query.eq('owner', db.User.me);
}
return (await query.resultList()).map((app: { name: string }) => app.name);
}
function getDefaultApp(db: EntityManager) {
return getApps(db).then((apps) => {
if (apps.length === 1) {
return apps[0];
}
throw new Error('Please add the name of your app as a parameter.');
});
}
async function readInputCredentials(appInfo: AppInfo): Promise<UsernamePasswordCredentials> {
return inquirer.prompt([
{ name: 'username', type: 'input', message: appInfo.isCustomHost ? 'Username:' : 'E-Mail:' },
{ name: 'password', type: 'password', message: 'Password:' },
]);
}
function decrypt(input: string) {
// This is legacy and we will remove support for the username / password storage in the profile file
// eslint-disable-next-line node/no-deprecated-api
const decipher = crypto.createDecipher(algorithm, PROFILE_DEFAULT_KEY);
let decrypted = decipher.update(input, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
function writeProfileFile(json: ProfileJson) {
return helper.writeFile(fileName, JSON.stringify(json)).catch((e) => {
console.warn('Baqend Profile file can\'t be written', e);
throw e;
});
}
function readProfileFile(): Promise<ProfileJson> {
return isFile(fileName).then((exists) => {
if (!exists) {
return {};
}
return readFile(fileName, 'utf-8').then((data) => (data ? JSON.parse(data) : {}));
});
}
function saveCredentials(appInfo: AppInfo, credentials: TokenCredentials) {
return readProfileFile().then((json) => {
// eslint-disable-next-line no-param-reassign
json[appInfo.host] = { ...json[appInfo.host], ...credentials };
return writeProfileFile(json);
});
}