UNPKG

baqend

Version:

Baqend JavaScript SDK

426 lines (358 loc) 12.9 kB
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); }); }