UNPKG

atlassian-connect-express

Version:

Library for building Atlassian Add-ons on top of Express

297 lines (261 loc) 9.13 kB
const request = require("request"); const _ = require("lodash"); const moment = require("moment"); const jwt = require("atlassian-jwt"); const URI = require("urijs"); const OAuth2 = require("./oauth2"); const utils = require("./utils"); class HostClient { constructor(addon, context, clientKey) { utils.checkNotNull(addon, "addon"); utils.checkNotNull(addon.settings, "addon.settings"); this.addon = addon; this.context = context || {}; this.clientKey = clientKey; this.oauth2 = new OAuth2(addon); } defaults(options) { return request.defaults.apply(null, this.modifyArgs(options)); } cookie() { return request.cookie.apply(null, arguments); } jar() { return request.jar(); } /** * Make a request to the host product as the specific user. Will request and retrieve an access token if necessary * * @param userKey - the key referencing the remote user to impersonate when making the request * @returns HostClient - `hostClient` object suitable for chaining */ asUser(userKey) { if (!userKey) { throw new Error("A userKey must be provided to make a request as a user"); } const product = this.addon.config.product(); if (!product.isJIRA && !product.isConfluence) { throw new Error( `the asUser method is not available for ${product.id} add-ons` ); } // Warn that this is deprecated console.warn( "This has been deprecated, as per https://ecosystem.atlassian.net/browse/ACEJS-115" ); const impersonatingClient = new HostClient( this.addon, this.context, this.clientKey ); impersonatingClient.userKey = userKey; return impersonatingClient; } /** * Make a request to the host product as the specific user. Will request and retrieve and access token if necessary * * @param userAccountId - the Atlassian Account Id of the remote user to impersonate when making the request * @returns HostClient - `hostClient` object suitable for chaining. */ asUserByAccountId(userAccountId) { if (!userAccountId) { throw new Error( "A userAccountId must be provided to make a request as a user" ); } const product = this.addon.config.product(); if (!product.isJIRA && !product.isConfluence) { throw new Error( `the asUserByAccountId method is not available for ${product.id} add-ons` ); } const impersonatingClient = new HostClient( this.addon, this.context, this.clientKey ); impersonatingClient.userAccountId = userAccountId; return impersonatingClient; } modifyArgs(options, augmentHeaders, callback, clientSettings) { return utils.modifyArgs( this.addon, options, augmentHeaders, callback, clientSettings.baseUrl ); } createJwtPayload(req, iss = this.addon.key) { const now = moment().utc(), jwtTokenValidityInMinutes = this.addon.config.jwt().validityInMinutes; const token = { iss, iat: now.unix(), exp: now.add(jwtTokenValidityInMinutes, "minutes").unix(), qsh: jwt.createQueryStringHash(jwt.fromExpressRequest(req)) }; if (this.addon.config.product().isBitbucket) { token.sub = this.clientKey; } else if ( this.addon.config.product().isJIRA || this.addon.config.product().isConfluence ) { token.aud = [this.clientKey]; } return token; } getUserBearerToken(scopes, clientSettings) { utils.checkNotNull(clientSettings.baseUrl, "clientSettings.baseUrl"); utils.checkNotNull( clientSettings.oauthClientId, "clientSettings.oauthClientId" ); utils.checkNotNull( clientSettings.sharedSecret, "clientSettings.sharedSecret" ); if (this.userAccountId) { return this.oauth2.getUserBearerTokenByUserAccountId( this.userAccountId, scopes, clientSettings ); } else if (this.userKey) { return this.oauth2.getUserBearerToken( this.userKey, scopes, clientSettings ); } else { throw new Error( "One of userAccountId or userKey must be provided. Did you call asUserByAccountId(userAccountId)?" ); } } } function crossProtocolRedirectGuard(response) { if (!response.headers["location"]) { return true; } if ( !response.request || !response.request.uri || !response.request.uri.protocol ) { return true; } const locationUri = new URI(response.headers["location"]); const requestUri = new URI(response.request.uri.href); if (!locationUri || !locationUri.protocol || !locationUri.protocol()) { return true; } return locationUri.protocol() === requestUri.protocol(); } const safeRequestDefaults = { followRedirect: crossProtocolRedirectGuard }; ["get", "post", "put", "del", "head", "patch"].forEach(method => { // hostClient.get -> return function // hostClient.get(options, callback) -> get client settings -> augment options -> callback HostClient.prototype[method] = function (options, callback) { const self = this; return this.addon.settings .get("clientInfo", this.clientKey) .then(clientSettings => { if (!clientSettings) { const message = `There are no "clientInfo" settings in the store for tenant "${self.clientKey}"`; self.addon.logger.warn(message); return Promise.reject(message); } const clientContext = { clientSettings }; if (self.userKey || self.userAccountId) { return self.getUserBearerToken([], clientSettings).then(token => { clientContext.bearerToken = token.access_token; return Promise.resolve(clientContext); }); } else { return Promise.resolve(clientContext); } }) .then( clientContext => { const augmentHeaders = function (headers, relativeUri) { const uri = new URI(relativeUri); const query = uri.search(true); const httpMethod = method === "del" ? "delete" : method; headers["User-Agent"] = self.addon.config.userAgent(self.addon.key); // don't authenticate the request, which can be useful for running operations // as an "anonymous user" such as evaluating permissions if (options.anonymous) { return; } if (!clientContext.bearerToken) { const jwtPayload = self.createJwtPayload( { method: httpMethod, path: uri.path(), query }, clientContext.clientSettings.key ), jwtToken = jwt.encodeSymmetric( jwtPayload, clientContext.clientSettings.sharedSecret, "HS256" ); headers.authorization = `JWT ${jwtToken}`; } else { headers.authorization = `Bearer ${clientContext.bearerToken}`; } }; try { const args = self.modifyArgs( options, augmentHeaders, callback, clientContext.clientSettings ); /* TODO (ONECLOUD-353): convert request => node-fetch as request is deprecated. */ /*For GET method, drop request body: To be compliant with https://developer.atlassian.com/cloud/jira/platform/changelog/#CHANGE-2328 and https://developer.atlassian.com/cloud/confluence/changelog/#CHANGE-2328 */ const multipartFormData = method === "get" ? null : options.multipartFormData; delete options.multipartFormData; if (method === "get") { delete options.body; delete options.formData; // If options.json is true and there is no other body, it only adds the "accept: application/json" header. so we keep it. // When options.json is an object or any type other than boolean, it will be used as the request body, so we need to remove it. if (options.json !== true) { delete options.json; } } const _request = request .defaults(safeRequestDefaults) [method].apply(null, args); if (multipartFormData) { const form = _request.form(); _.forOwn(multipartFormData, (value, key) => { if (Array.isArray(value)) { form.append.apply(form, [key].concat(value)); } else { form.append.apply(form, [key, value]); } }); } return _request; } catch (err) { self.addon.logger.error(err); callback(err); } }, err => { self.addon.logger.error(err); callback(err); } ); }; }); module.exports = HostClient;