@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
711 lines (607 loc) • 27.4 kB
JavaScript
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
};