atlassian-connect-express
Version:
Library for building Atlassian Add-ons on top of Express
297 lines (261 loc) • 9.13 kB
JavaScript
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;