'use strict'; const constants = require('./constants'); const axios = require('axios'); const url = require('url'); const https = require('https'); // use environment variable DEBUG with value 'xssec:*' for trace/error messages const debug = require('debug'); const debugTrace = debug('xssec:requests'); const debugError = debug('xssec:requests'); debugError.log = console.error.bind(console); debugTrace.log = console.log.bind(console); const IAS = "ias"; const XSUAA = "xsuaa"; const X_ZONE_ID_HEADER_NAME = "x-zid"; const CORRELATIONID_HEADER = "x-vcap-request-id"; const DEFAULT_TIMEOUT = 2000; const DEFAULT_USER_TOKEN_TIMEOUT = 10 * 1000; /** * Converts form parameters object to an array of key value pairs. * Parameters with array values are converted to multiple key-value pairs with the same key. * Parameters with object values are converted to a key-value pair with the object value stringified to JSON. * @param {*} params key-value object with type of values being string|Array|object * @returns array of key-value pairs */ function toFormArray(params) { return Object.entries(params) .filter(([value]) => value != null) .flatMap(([key, value]) => { if(Array.isArray(value)) { return value.map(arrayValue => [key, arrayValue]); } if(typeof value === 'object') { return [[key, JSON.stringify(value)]]; } return [[key, value]]; }); } async function _requestToNetworkAXIOS(fnc, options, cb) { if (debugTrace.enabled) { let opt = JSON.parse(JSON.stringify(options)); if (opt.https && opt.https.key) { opt.https.key = ""; } if (opt.form && opt.form.client_secret) { opt.form.client_secret = "" } if (opt.form && opt.form.cert) { opt.form.cert = "" } if (opt.form && opt.form.assertion) { opt.form.assertion = "" } debugTrace(fnc + '::HTTP Call with %O', opt); } const axios_options = { maxRedirects: 0, //no followRedirect headers: options.headers, url: options.url, method: options.method, timeout: options.timeout || DEFAULT_TIMEOUT }; if(options.params) { axios_options.params = options.params; } if (options.form) { const formData = options.configType?.toLowerCase() === IAS ? toFormArray(options.form) : options.form; axios_options.data = new url.URLSearchParams(formData).toString(); } if (options.https) { axios_options.httpsAgent = new https.Agent({ cert: options.https.certificate, key: options.https.key }); } try { const result = await axios(axios_options); const json = result.data; if (options._responseToken) { cb(null, json[options._responseToken], json); } else { cb(null, json.id_token || json.access_token || json, json); } } catch (e) { return cb(e); } } let _requestToNetwork = _requestToNetworkAXIOS; function validateParameters(serviceCredentials, cb) { // input validation if (!serviceCredentials) { return new Error('Parameter serviceCredentials is missing but mandatory.'); } if (!serviceCredentials.clientid) { //client_secret will be checked later (only if really needed) return new Error('Invalid service credentials: Missing clientid.'); } if (!serviceCredentials.url) { return new Error('Invalid service credentials: Missing url.'); } if (!cb || typeof cb !== 'function') { return new Error('No callback function provided.'); } } function buildSubdomain(serviceCredentials, subdomain) { var urlWithCorrectSubdomain = serviceCredentials.url; if (subdomain) { var tokenSubdomain = subdomain; var tokenRequestSubdomain = null; var uaaUrl = url.parse(serviceCredentials.url); if (uaaUrl.hostname.indexOf('.') === -1) { tokenRequestSubdomain = null; } else { tokenRequestSubdomain = uaaUrl.hostname.substring(0, uaaUrl.hostname.indexOf('.')); } if (tokenSubdomain !== null && tokenRequestSubdomain != null && tokenSubdomain !== tokenRequestSubdomain) { urlWithCorrectSubdomain = uaaUrl.protocol + "//" + tokenSubdomain + uaaUrl.host.substring(uaaUrl.host.indexOf('.'), uaaUrl.host.size); } if (serviceCredentials.certificate) { urlWithCorrectSubdomain = urlWithCorrectSubdomain.replace(".authentication.", ".authentication.cert.") } } else if (serviceCredentials.certificate && serviceCredentials.certurl) { urlWithCorrectSubdomain = serviceCredentials.certurl; } return urlWithCorrectSubdomain; } function appendAdditonalAttributes(options, additionalAttributes) { if (additionalAttributes !== null) { options.form.authorities = { "az_attr": additionalAttributes }; } } function DefaultHeaders(zoneId, type) { var ret = { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': constants.USER_AGENT }; if (zoneId && type !== IAS) { ret[X_ZONE_ID_HEADER_NAME] = zoneId; } return ret; } function buildOptions(serviceCredentials, additionalAttributes, url, grantType, appTid, attributes) { // jwt bearer flow const path = attributes.configType === 'ias' ? '/oauth2/token' : '/oauth/token'; const options = { method: 'POST', url: url + path, headers: DefaultHeaders(appTid, attributes.configType), form: { grant_type: grantType, response_type: 'token', client_id: serviceCredentials.clientid, app_tid: appTid || serviceCredentials.app_tid }, timeout: attributes.timeout, configType: attributes.configType }; if (attributes.resource) { options.form.resource = attributes.resource; } if (serviceCredentials.certificate) { options.https = { key: serviceCredentials.key, certificate: serviceCredentials.certificate } } else { //make sure we have a client_secret set here if (!serviceCredentials.clientsecret) { throw new Error('Invalid config: Missing clientsecret.'); } options.form.client_secret = serviceCredentials.clientsecret; } appendAdditonalAttributes(options, additionalAttributes); if (attributes.correlationId) { options.headers[CORRELATIONID_HEADER] = attributes.correlationId; } if (attributes.scopes) { options.form.scope = attributes.scopes; } if (attributes.username) { options.form.username = attributes.username; options.form.password = attributes.password; } return options; } module.exports.requestOpenIDConfiguration = function (serviceCredentialsUrl, attributes, cb) { var options = { method: 'GET', followRedirect: false, timeout: DEFAULT_TIMEOUT, url: serviceCredentialsUrl + '/.well-known/openid-configuration', headers: { 'Accept': 'application/json', 'User-Agent': constants.USER_AGENT } }; if (attributes) { if (attributes.correlationId) { options.headers[CORRELATIONID_HEADER] = attributes.correlationId; } } return _requestToNetwork(".well-known", options, cb); } module.exports.fetchOIDCKey = function (serviceCredentialsUrl, params = {}, cb) { var options = { method: 'GET', url: serviceCredentialsUrl, followRedirect: false, timeout: DEFAULT_TIMEOUT, headers: { 'Accept': 'application/json', 'User-Agent': constants.USER_AGENT } }; params.client_id && (options.headers["x-client_id"] = params.client_id); params.app_tid && (options.headers["x-app_tid"] = params.app_tid); params.azp && (options.headers["x-azp"] = params.azp); params.correlationId && (options.headers[CORRELATIONID_HEADER] = params.correlationId); return _requestToNetwork(".oidc-jkws", options, cb); } function getServiceCredentials(config) { if (config.credentials) { return config.credentials; } return config; } function getAttributes(config, defaultTimeout, maxTimeout) { if (config.credentials) { let timeout = config.timeout || defaultTimeout; if (timeout > maxTimeout) { timeout = maxTimeout; } else if (timeout < DEFAULT_TIMEOUT) { timeout = DEFAULT_TIMEOUT; } if (config.type === "IAS") { config.type = IAS; } return { configType: config.type || XSUAA, scopes: config.scopes, correlationId: config.correlationId, timeout: timeout, username: config.username, password: config.password, resource: config.resource }; } return { timeout: defaultTimeout, configType: XSUAA }; } module.exports.requestUserToken = function (appToken, config, additionalAttributes, scopes, subdomain, zoneId, cb) { //make it backward-compatible (where zoneId is not provided at all) if (typeof zoneId === 'function') { cb = zoneId; zoneId = null; } const serviceCredentials = getServiceCredentials(config); const attributes = getAttributes(config, DEFAULT_USER_TOKEN_TIMEOUT, 10 * 1000); var error = validateParameters(serviceCredentials, cb); if (error) { error.statuscode = 500; return cb(error, null); } const urlWithCorrectSubdomain = buildSubdomain(serviceCredentials, subdomain); try { // jwt bearer flow var options = buildOptions(serviceCredentials, additionalAttributes, urlWithCorrectSubdomain, 'urn:ietf:params:oauth:grant-type:jwt-bearer', zoneId, attributes); //add Assertion options.form.assertion = appToken; if (scopes !== null) { options.form.scope = scopes; } return _requestToNetwork("requestUserToken", options, cb); } catch (e) { //the verification of the serviceCredentials fails e.statuscode = 500; return cb(e); } } module.exports.requestPasswordUserToken = function (subdomain, config, additionalAttributes, cb) { const serviceCredentials = getServiceCredentials(config); const attributes = getAttributes(config, DEFAULT_USER_TOKEN_TIMEOUT, 10 * 1000); // input validation const error = validateParameters(serviceCredentials, cb); if (error) { error.statuscode = 500; return cb(error, null); } // adapt subdomain in service url, if necessary const urlWithCorrectSubdomain = buildSubdomain(serviceCredentials, subdomain); try { const options = buildOptions(serviceCredentials, additionalAttributes, urlWithCorrectSubdomain, 'password', null, attributes); appendAdditonalAttributes(options, additionalAttributes); options._responseToken = 'access_token'; return _requestToNetwork("requestPasswordUserToken", options, cb); } catch (e) { //the verification of the serviceCredentials fails e.statuscode = 500; return cb(e); } }; module.exports.requestClientCredentialsToken = function (subdomain, config, additionalAttributes, zoneId, cb) { //make it backward-compatible (where zoneId is not provided at all) if (typeof zoneId === 'function') { cb = zoneId; zoneId = null; } const serviceCredentials = getServiceCredentials(config); const attributes = getAttributes(config, DEFAULT_TIMEOUT, 5 * 1000); // input validation const error = validateParameters(serviceCredentials, cb); if (error) { error.statuscode = 500; return cb(error, null); } // adapt subdomain in service url, if necessary const urlWithCorrectSubdomain = buildSubdomain(serviceCredentials, subdomain); try { const options = buildOptions(serviceCredentials, additionalAttributes, urlWithCorrectSubdomain, 'client_credentials', zoneId, attributes); appendAdditonalAttributes(options, additionalAttributes); return _requestToNetwork("requestClientCredentialsToken", options, cb); } catch (e) { //the verification of the serviceCredentials fails e.statuscode = 500; return cb(e); } }; module.exports.fetchKeyFromXSUAA = async function (tokenKeyUrl, zid, attributes, cb) { const options = { headers: { "User-Agent": constants.USER_AGENT }, method: "GET", url: tokenKeyUrl, followRedirect: false, timeout: DEFAULT_TIMEOUT }; if (zid) { options.params = { zid }; } if (attributes) { if (attributes.correlationId) { options.headers[CORRELATIONID_HEADER] = attributes.correlationId; } } _requestToNetwork("fetchKeyFromXSUAA", options, cb); } module.exports.__patchNetwork = new function () { var oldrequestToXSUAA = _requestToNetwork; this.patch = function (fnc) { if (typeof fnc === 'function') { debugTrace("patch XSUAA communication to another function"); _requestToNetwork = fnc; } else { if (_requestToNetwork !== oldrequestToXSUAA) { debugTrace("patch XSUAA communication to original function"); _requestToNetwork = oldrequestToXSUAA; } } } }