UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

711 lines (607 loc) 27.4 kB
const fs = require('fs'); const os = require('os'); const path = require('path'); const axios = require('axios'); const cds = require('../cds'); const { requireGlobal } = require('./util/dependencies'); const { getAppFromSuggestions, getSubdomain } = require('./util/cf'); const isBas = require('./util/env'); const { getMessage } = require('./util/logging'); const Question = require('./util/question'); const { httpSchema, httpsSchema, schemaRegex, localhostRegex } = require('./util/urls'); const { capitalize } = require('./util/strings'); const DEBUG = cds.debug('cli'); const { ParamCollection, Persistence } = require('./params'); const CONFIG_SUBDIRS = { linux: '.config', darwin: path.join('Library', 'Preferences'), win32: 'AppData' }; const TOKEN_STORAGE_DESC = { plain: 'plain-text storage', keyring: 'keyring' }; const KEYRING_DESC = { linux: 'libsecret', darwin: 'Keychain', win32: 'Credential Vault' }; const MTX_FULLY_QUALIFIED = 'com.sap.cds.mtx'; const OAUTH_PATH_LEGACY = '/mtx/v1/oauth/token'; const OAUTH_PATH = '/-/cds/login/token'; const OAUTH_META_PATH = '/-/cds/login/authorization-metadata'; const SETTINGS_DIR = path.join(os.homedir(), CONFIG_SUBDIRS[os.platform()] || '', MTX_FULLY_QUALIFIED); const SETTINGS_FILE = 'projects.json'; const AUTH_FILE = 'auth.json'; const CONFIG = { paths: { settings: path.join(SETTINGS_DIR, SETTINGS_FILE), auth: path.join(SETTINGS_DIR, AUTH_FILE) }, keyringDesignation: KEYRING_DESC[os.platform()] || 'not supported' }; let keytar; let keytarDisabled; function other(tokenStorage) { if (!tokenStorage) { return tokenStorage; } return tokenStorage === 'plain' ? 'keyring' : 'plain'; } function getProjectFolder(params) { if (params.has('projectFolder')) { const maybeExistentPath = path.resolve(params.get('projectFolder')); return maybeExistentPath; } return fs.realpathSync('.'); } async function getAppUrlAndSubdomainFromSuggestions(params) { const app = await getAppFromSuggestions(); if (!app) { return; } params.set('appUrl', app.url); if (!params.has('subdomain')) { params.set('subdomain', await getSubdomainFromCfAndNotify(app.name)); } } async function getSubdomainFromCfAndNotify(appName) { const subdomain = await getSubdomain(appName); if (subdomain) { console.log('Subdomain determined from CF app environment:', subdomain); } else { console.warn('Failed to determine subdomain from CF app environment'); } return subdomain; } function appUrlWith(pathname, params) { const url = new URL(params.get('appUrl')); url.pathname = url.pathname.replace(/\/*$/, pathname); return url.toString(); } async function getTokenUrl(params, renewUrl) { if (params.has('tokenUrl') && !renewUrl) { return params.get('tokenUrl'); } if (!params.has('appUrl')) { throw 'Failed to determine token URL: app URL not given'; } return appUrlWith(OAUTH_PATH, params); } async function fetchPasscodeUrl(params) { const url = appUrlWith(OAUTH_META_PATH, params); DEBUG?.(`Trying to get passcode URL from GET`, url); let responseData; try { const config = params.has('subdomain') ? { params: { subdomain: params.get('subdomain') } } : undefined; const response = await axios.get(url, config /* no auth required */); responseData = response.data; } catch (error) { if (error.status === 404) { DEBUG?.(`Request unsupported by server, ignoring (${error.message.replace(/ *Details:.*/s, '')})`); } else { DEBUG?.(getMessage(`Getting passcode URL failed`, { error })); } return undefined; } DEBUG?.(`Received ${JSON.stringify(responseData)}`); if (responseData.includes?.('<html')) { throw 'HTML response received. Check if route to MTX is configured correctly in App Router.'; } if (responseData.signed_metadata) { DEBUG?.(`Unsupported signed metadata received. Since these should take precedence, ignoring response.`); return undefined; } return responseData.passcode_url; } function getKeyringAccountName(params) { return `${params.get('appUrl')}|${params.get('subdomain')}`; } function getAccountUrl(account) { return account.includes('|') ? account.split('|')[0] : null; } function getAccountSubdomain(account) { return account.includes('|') ? account.split('|')[1] : null; } function logConfigPaths() { DEBUG?.(`Settings are stored in ${CONFIG.paths.settings}`); DEBUG?.(`Authentication data is stored in ${CONFIG.paths.auth} (${TOKEN_STORAGE_DESC.plain}) or ${CONFIG.keyringDesignation} (${TOKEN_STORAGE_DESC.keyring})`); } function notifyLogin(params) { return params.get('renewLogin') ? console.log.bind(console) : DEBUG; } function emptyData(all) { return all ? {} : new ParamCollection(); } class SettingsManager { static init() { logConfigPaths(); keytar = undefined; keytarDisabled = undefined; } static get config() { return CONFIG; } // Call with 'projectFolder' param as a resolved path. static async saveSettings(params) { DEBUG?.(`Saving settings for project ${params.get('projectFolder')}`); const paramsMap = params.toEntries(Persistence.setting); if (params.has('username')) { // Save username only for localhost for security reasons. if (localhostRegex.test(params.get('appUrl'))) { console.log(`Saving username${(params.get('isEmptyPassword') ? ' and empty-password hint' : '')} with project settings.`); } else { DEBUG?.('Not saving username because app is not recognized to run on localhost.'); delete paramsMap.username; delete paramsMap.isEmptyPassword; } } await this._saveToFile(params.get('projectFolder'), paramsMap); if (params.get('skipToken') || !params.has('token')) { return; } if (params.get('clearOtherTokenStorage')) { // Delete data from the other storage. params.delete('clearOtherTokenStorage'); await this.deleteToken(params, { fromOtherStorage: true }); } if (params.get('tokenStorage') === 'plain') { await this._saveAuthToFile(params, params.toEntries(Persistence.auth)); } else if (params.get('tokenStorage') === 'keyring') { await this.setKeytar(params); await this._saveAuthToKeyring(params, params.toEntries(Persistence.auth)); } else { console.log('Note: authentication data is not persisted by default. To save tokens for later commands, please run `cds login`.'); } } static async loadAndMergeSettings(params, logout = false) { const projectFolder = getProjectFolder(params); params.set('projectFolder', projectFolder); const loadedSettings = await this._loadFromFile(projectFolder); DEBUG?.(`Loaded project settings for ${params.get('projectFolder')}: ${loadedSettings.format()}`); const clashing = ['passcode', 'clientid', 'username'].filter(param => params.has(param)); if (clashing.length > 1) { throw `Conflicting parameters: ${clashing.join(', ')}. Please provide only one of them.`; } if (params.has('passcode') && loadedSettings.has('username')) { console.log(`Discarding saved username${(loadedSettings.has('isEmptyPassword') ? ' and empty-password hint' : '')}: passcode given`); loadedSettings.delete('username'); loadedSettings.delete('isEmptyPassword'); } params.mergeLower(loadedSettings); if (params.has('username')) { params.set('skipToken', true); } await this.updateUrls(params, logout, loadedSettings); await this.setKeytar(params, logout); await this.addAuth(params, logout); DEBUG?.(`Effective project settings: ${params.format()}`); } static async updateUrls(params, logout, loadedSettings) { function secure(url, label) { if (!url) { return url; } if (url.startsWith(httpSchema) && !localhostRegex.test(url)) { url = url.replace(httpSchema, httpsSchema); DEBUG?.(`Replaced HTTP with HTTPS in ${label}: ${url}`); } else if (!schemaRegex.test(url)) { if (localhostRegex.test(url)) { url = httpSchema + url; } else { url = httpsSchema + url; } DEBUG?.(`Added schema to ${label}: ${url}`); } return url; } async function updateAppUrl() { if (!logout && !params.has('appUrl')) { // Get appUrl from CF DEBUG?.('App URL not given'); await getAppUrlAndSubdomainFromSuggestions(params); } if (!params.has('appUrl')) { throw 'App URL not given. Please specify it or log in to Cloud Foundry and repeat this command.'; } // Prefix URL schema let appUrl = secure(params.get('appUrl'), 'app URL'); if (appUrl.endsWith('/')) { appUrl = appUrl.replace(/\/+$/, ''); DEBUG?.(`Removed trailing slash from app URL: ${appUrl}`); } params.set('appUrl', appUrl); if (params.get('appUrl') !== loadedSettings.get('appUrl')) { DEBUG?.(`Updated app URL from loaded value '${loadedSettings.get('appUrl')}'`); } console.log(`App URL: ${params.get('appUrl')}`); } async function updateTokenUrl() { const tokenUrl = secure(params.get('tokenUrl'), 'token URL'); const renewUrl = !logout && !params.get('skipToken') && (!tokenUrl || tokenUrl.includes('//-/cds') || tokenUrl.includes(OAUTH_PATH_LEGACY) || params.get('appUrl') !== loadedSettings.get('appUrl')); if (renewUrl) { const tokenUrl = await getTokenUrl(params, renewUrl); params.set('tokenUrl', tokenUrl); DEBUG?.(`Updated token URL from '${loadedSettings.get('tokenUrl')}' (loaded) to '${tokenUrl}'`); } } await updateAppUrl(); await updateTokenUrl(); } static async setKeytar(params, logout = false) { if (keytar || keytarDisabled || params.get('skipToken')) { return; } if (isBas()) { if (params.get('tokenStorage') === 'keyring') { console.log('NOTE:', capitalize(TOKEN_STORAGE_DESC.keyring), 'not supported on SAP Business Application Studio. Switching to', TOKEN_STORAGE_DESC.plain, '.'); params.set('tokenStorage', 'plain'); } return; } keytar = requireGlobal('keytar'); if (keytar) { return; } keytarDisabled = true; if (params.get('tokenStorage') === 'keyring' && params.get('saveData')) { // Explicit login w/o plain-text storage enabled throw capitalize(TOKEN_STORAGE_DESC.keyring) + ' requested but keytar not installed. ' + 'Run `npm install -g keytar` or switch to ' + TOKEN_STORAGE_DESC.plain + ' by adding `--plain` (discouraged).'; } const doLog = params.get('saveData') || logout ? console.log.bind(console) // Explicit login/logout call : DEBUG; doLog?.(`Disabling ${TOKEN_STORAGE_DESC.keyring} functionality: keytar not found. Run \`npm install -g keytar\` to install it.`); } static async addAuth(params, logout) { async function addPassword() { if (params.get('isEmptyPassword')) { params.set('password', ''); } else { params.set('password', await Question.askQuestion('Password: ', undefined, true)); console.log(); } } async function addClientSecret() { params.set('clientsecret', await Question.askQuestion('clientsecret: ', undefined, true)); console.log(); if (params.get('clientsecret') === '') { throw 'Clientsecret cannot be empty'; } } async function addKey() { params.set('key', await Question.askQuestion('key: ', undefined, true)); console.log(); if (params.get('key') === '') { throw 'Key cannot be empty'; } } async function addPasscode() { if (!params.has('passcodeUrl')) { const passcodeUrl = await fetchPasscodeUrl(params); if (passcodeUrl) { params.set('passcodeUrl', passcodeUrl); } } const prompt = `Passcode${params.has('passcodeUrl') ? ' (visit ' + params.get('passcodeUrl') + ' to generate)' : ''}: `; params.set('passcode', (await Question.askQuestion(prompt, undefined, true)).trim()); console.log(); if (params.get('passcode') === '') { throw 'Passcode cannot be empty'; } } async function addAuthSettings() { function removeObsoleteToken() { if (params.get('renewLogin') || params.get('tokenExpirationDate') <= Date.now()) { DEBUG?.((params.get('renewLogin') ? 'Renewing' : 'Refreshing expired') + ' authentication token'); params.delete('token'); params.delete('tokenExpirationDate'); } } const auth = { plain: await SettingsManager._loadAuthFromFile(params.get('appUrl'), params.get('subdomain')), keyring: await SettingsManager._loadAuthFromKeyring(params.get('appUrl'), params.get('subdomain')) }; if (!(auth.keyring.has('tokenStorage') || auth.plain.has('tokenStorage'))) { // Saved auth data not present. return; } let storage = params.get('tokenStorage'); if (auth.keyring.has('tokenStorage') && auth.plain.has('tokenStorage')) { // Both storage places contain data: retrieve from selected or keyring. storage = storage || 'keyring'; DEBUG?.('WARNING: authentication data found in both kinds of storage. ' + `Using data from ${TOKEN_STORAGE_DESC[storage]}; other storage will be cleared when next saving.`); params.merge(auth[storage]); params.set('clearOtherTokenStorage', true); } else if (storage && auth[other(storage)].has('tokenStorage')) { // Selected storage contains no data, but other one does: retrieve from other storage, but earmark migration to selected one. DEBUG?.(`Using authentication data from ${TOKEN_STORAGE_DESC[other(storage)]}; will be migrated to other storage on save.`); params.merge(auth[other(storage)]); params.set('tokenStorage', storage); params.set('clearOtherTokenStorage', true); } else { // One storage contains data, and there's no conflict with selection. const storage = auth.keyring.get('tokenStorage') || auth.plain.get('tokenStorage'); DEBUG?.(`Using authentication data from ${TOKEN_STORAGE_DESC[storage]}.`); params.merge(auth[storage]); } if (params.has('token')) { removeObsoleteToken(); } } if (params.has('username')) { if (!params.has('password') && !logout) { await addPassword(); } if (params.get('password') === '') { params.set('isEmptyPassword', true); } else { params.delete('isEmptyPassword'); } DEBUG?.('Ignoring any saved authentication data because username is given'); return; } else { params.delete('isEmptyPassword'); } if (params.get('skipToken')) { return; } if (params.get('clientid') && !logout) { if (params.has('key')) { if (params.get('key') === 'ask') { params.delete('key'); await addKey(); } } else if (!params.has('clientsecret')) { await addClientSecret(); } } await addAuthSettings(); if (!logout && !params.has('token') && !params.has('refreshToken') && !params.has('passcode') && !params.has('clientsecret') && !params.has('key')) { await addPasscode(); } } static async deleteToken(params, { fromOtherStorage = false, invalid = false } = {}) { const allParams = params.clone(); await this.loadAndMergeSettings(allParams, true); const target = `URL ${allParams.get('appUrl')}, subdomain '${allParams.get('subdomain')}'`; let fromStorage = fromOtherStorage && other(params.get('tokenStorage')); const deleteBoth = !fromStorage && allParams.get('tokenStorage') && allParams.get('clearOtherTokenStorage'); DEBUG?.(`Deleting${invalid ? ' invalid' : ''} authentication data${fromOtherStorage ? ' from other storage' : ''} for ${target}`); fromStorage = fromStorage || allParams.get('tokenStorage'); if (!fromStorage) { if (!invalid) { console.log('Failed to delete authentication data: none found for', target); } return; } if (fromStorage === 'plain' || deleteBoth) { await this._saveAuthToFile(allParams, null); } if (fromStorage === 'keyring' || deleteBoth) { await this._saveAuthToKeyring(allParams, null); } } static async deleteSettingsWithoutToken(params) { const projectFolder = getProjectFolder(params); await this._saveToFile(projectFolder, null); } static async deleteInvalidSettings() { const settingsByFolder = await this._loadFromFile(undefined); const deletionFolders = []; const deletionUrlsAndSubdomains = new Set(); Object.entries(settingsByFolder) .filter(entry => ! fs.existsSync(entry[0])) .forEach(entry => { delete settingsByFolder[entry[0]]; deletionFolders.push(entry[0]); deletionUrlsAndSubdomains.add(entry[1].appUrl + '|' + entry[1].subdomain); }); await this._saveAllSettingsToFile(settingsByFolder); if (deletionFolders.length) { for (const urlAndSubdomain of deletionUrlsAndSubdomains.values()) { const appUrl = urlAndSubdomain.replace(/\|.*/, ''); const subdomain = urlAndSubdomain.replace(/.*\|/, ''); const urlReference = Object.values(settingsByFolder).find(settings => settings.appUrl === appUrl && settings.subdomain === subdomain); if (!urlReference) { await this.deleteToken(new ParamCollection({ appUrl, subdomain }), { invalid: true }); } } console.log('Deleted settings for nonexistent project folders:', deletionFolders.map(folder => ' ' + folder)); } else { console.log('All saved project folders seem valid'); } } static async deleteInvalidTokens() { async function deleteFrom(allAuth) { for (const [appUrl, authForUrl = {}] of Object.entries(allAuth)) { for (const [subdomain, auth] of Object.entries(authForUrl)) { if (!auth.token) { continue; } if (auth.tokenExpirationDate <= Date.now()) { await SettingsManager.deleteToken(new ParamCollection({ appUrl, subdomain }), { invalid: true }); } } } } await deleteFrom(await this._loadAuthFromFile(undefined, undefined)); await deleteFrom(await this._loadAllAuthFromKeyring()); } static async _saveToFile(projectFolder, paramValues) { const paramValuesByFolder = await this._loadFromFile(undefined); if (paramValues !== null) { paramValuesByFolder[projectFolder] = paramValues; await this._saveAllSettingsToFile(paramValuesByFolder); DEBUG?.(`Saved project settings: ${JSON.stringify(paramValues)}`); } else { delete paramValuesByFolder[projectFolder]; await this._saveAllSettingsToFile(paramValuesByFolder); console.log('Deleted project settings'); } } static async _saveAllSettingsToFile(paramValuesByFolder) { DEBUG?.(`Saving all settings to ${CONFIG.paths.settings}`); await fs.promises.mkdir(path.dirname(CONFIG.paths.settings), { recursive: true }) await fs.promises.writeFile(CONFIG.paths.settings, JSON.stringify(paramValuesByFolder, null, 2)); DEBUG?.('Saved settings'); } static async _saveAuthToFile(params, authValues) { const appUrl = params.get('appUrl'); const location = `${TOKEN_STORAGE_DESC.plain} at ${CONFIG.paths.auth} for app URL ${appUrl}, subdomain ${params.get('subdomain')}`; const allAuthValues = await this._loadAuthFromFile(undefined, undefined); if (authValues !== null) { (allAuthValues[appUrl] || (allAuthValues[appUrl] = {}))[params.get('subdomain')] = authValues; await fs.promises.mkdir(path.dirname(CONFIG.paths.auth), { recursive: true }) await fs.promises.writeFile(CONFIG.paths.auth, JSON.stringify(allAuthValues, null, 2)); notifyLogin(params)?.(`Saved authentication data to ${location}`); } else if (allAuthValues[appUrl]) { delete allAuthValues[appUrl][params.get('subdomain')]; if (Object.keys(allAuthValues[appUrl]).length === 0) { delete allAuthValues[appUrl]; } await fs.promises.mkdir(path.dirname(CONFIG.paths.auth), { recursive: true }) await fs.promises.writeFile(CONFIG.paths.auth, JSON.stringify(allAuthValues, null, 2)); console.log('Deleted authentication data from', location); } else { notifyLogin(params)?.(`No authentication data to delete from ${location}`); } } static async _saveAuthToKeyring(params, auth) { if (!keytar) { return; } const location = `${TOKEN_STORAGE_DESC.keyring} for app URL ${params.get('appUrl')}, subdomain ${params.get('subdomain')}`; if (auth !== null) { await keytar.setPassword(MTX_FULLY_QUALIFIED, getKeyringAccountName(params), JSON.stringify(auth)); notifyLogin(params)?.(`Saved authentication data to ${location}`); } else { await keytar.deletePassword(MTX_FULLY_QUALIFIED, getKeyringAccountName(params)); console.log('Deleted authentication data from', location); } } static async _loadFromFile(projectFolder) { const all = !projectFolder; if (!fs.existsSync(CONFIG.paths.settings)) { DEBUG?.('Settings file not found'); return emptyData(all); } let settingsByFolder try { settingsByFolder = JSON.parse((await fs.promises.readFile(CONFIG.paths.settings)).toString()); } catch (err) { DEBUG?.('Empty settings file'); return emptyData(all); } if (all) { // return settings for all projects return settingsByFolder; } if (!settingsByFolder[projectFolder]) { DEBUG?.(`No settings found for project ${projectFolder}`); return new ParamCollection(); } return new ParamCollection(settingsByFolder[projectFolder]); } static async _loadAuthFromFile(appUrl, subdomain) { const all = !appUrl; if (!fs.existsSync(CONFIG.paths.auth)) { DEBUG?.('Authentication-data file not found'); return emptyData(all); } let allAuth; try { allAuth = JSON.parse((await fs.promises.readFile(CONFIG.paths.auth)).toString()); } catch (err) { DEBUG?.(`${capitalize(TOKEN_STORAGE_DESC.plain)} contains invalid saved auth`); return emptyData(all); } if (all) { // return auth data for all projects return allAuth; } if (!allAuth[appUrl]) { DEBUG?.(`${capitalize(TOKEN_STORAGE_DESC.plain)} contains no authentication data for app URL ${appUrl}`); return new ParamCollection(); } const authForSubdomain = allAuth[appUrl][subdomain]; if (!authForSubdomain) { DEBUG?.(`${capitalize(TOKEN_STORAGE_DESC.plain)} contains no authentication data for subdomain ${subdomain} (app URL: ${appUrl})`); return new ParamCollection(); } if (authForSubdomain.token) { authForSubdomain.tokenStorage = 'plain'; } return new ParamCollection(authForSubdomain); } static async _loadAuthFromKeyring(appUrl, subdomain) { if (!keytar) { return new ParamCollection(); } const authString = await keytar.getPassword(MTX_FULLY_QUALIFIED, getKeyringAccountName(new ParamCollection({ appUrl, subdomain }))); if (!authString) { DEBUG?.(`${capitalize(TOKEN_STORAGE_DESC.keyring)} contains no authentication data for URL ${appUrl} and subdomain '${subdomain}'`); return new ParamCollection(); } let auth; try { auth = JSON.parse(authString); } catch (error) { auth = {}; } if (auth.token) { auth.tokenStorage = 'keyring'; } return new ParamCollection(auth); } static async _loadAllAuthFromKeyring() { if (!keytar) { return {}; } return (await keytar.findCredentials(MTX_FULLY_QUALIFIED)) .reduce((result, { account, password: authString }) => { const appUrl = getAccountUrl(account); const subdomain = getAccountSubdomain(account); if (appUrl && subdomain) { try { (result[appUrl] ??= {})[subdomain] = JSON.parse(authString); } catch (error) { throw new Error(`${capitalize(TOKEN_STORAGE_DESC.keyring)} contains invalid saved auth for URL ${appUrl} and subdomain ${subdomain}`); } } return result; }, {}); } } module.exports = { SettingsManager, other, notifyLogin };