atlassian-connect-express
Version:
Library for building Atlassian Add-ons on top of Express
404 lines (369 loc) • 11.9 kB
JavaScript
// Handles the lifecycle "installed" event of a connect addon.
const urls = require("url");
const moment = require("moment");
const jwt = require("atlassian-jwt");
const _ = require("lodash");
const request = require("request");
const requestHandler = require("./request");
const utils = require("../internal/utils");
const CONNECT_INSTALL_KEYS_CDN_URL_STAGING =
"https://cs-migrations--cdn.us-east-1.staging.public.atl-paas.net";
const CONNECT_INSTALL_KEYS_ASAP_CDN_URL_STAGING =
"https://asap-distribution.us-west-2.staging.atl-asap.net";
const CONNECT_INSTALL_KEYS_CDN_URL_PRODUCTION =
"https://connect-install-keys.atlassian.com";
const CONNECT_INSTALL_KEYS_CDN_URL_FEDRAMP_PRODUCTION =
"https://cs-migrations--fedrampcdn.us-east-1.prod.cdn.atlassian-us-gov-mod.com";
const CONNECT_INSTALL_KEYS_CDN_URL_FEDRAMP_STAGING =
"https://cs-migrations--fedrampcdn.us-east-1.staging.cdn.atlassian-us-gov-mod.com";
const CONNECT_INSTALL_KEYS_ASAP_CDN_URL_FEDRAMP_STAGING =
"https://asap-distribution.us-west-2.staging.atl-auth-us-gov-mod.net";
const CONNECT_INSTALL_KEYS_ASAP_CDN_URL_PRODUCTION =
"https://asap-distribution.us-west-2.prod.atl-asap.net";
const CONNECT_INSTALL_KEYS_ASAP_CDN_URL_FEDRAMP_PRODUCTION =
"https://asap-distribution.us-west-2.prod.atl-auth-us-gov-mod.net";
function verify(addon) {
return function (req, res, next) {
function sendError(msg) {
const code = 401;
addon.logger.error("Installation verification error:", code, msg);
if (addon.config.expressErrorHandling()) {
next({
code,
message: msg
});
} else {
res.status(code).send(_.escape(msg));
}
}
const regInfo = req.body;
if (!regInfo || !_.isObject(regInfo)) {
sendError("No registration info provided.");
return;
}
// verify that the specified host is in the registration whitelist;
// this can be spoofed, but is a first line of defense against unauthorized registrations
const baseUrl = regInfo.baseUrl;
if (!baseUrl) {
sendError("No baseUrl provided in registration info.");
return;
}
const host = urls.parse(baseUrl).hostname;
const whitelisted = addon.config.whitelistRegexp().some(re => {
return re.test(host);
});
if (!whitelisted) {
return sendError(
`Host at ${baseUrl} is not authorized to register as the host does not match the ` +
`registration whitelist (${addon.config.whitelist()}).`
);
}
const clientKey = regInfo.clientKey;
if (!clientKey) {
sendError(`No client key provided for host at ${baseUrl}.`);
return;
}
addon.authenticateInstall()(req, res, next);
};
}
function authenticateInstall(addon) {
return function (req, res, next) {
function sendError(msg) {
const code = 401;
addon.logger.error("Installation verification error:", code, msg);
if (addon.config.expressErrorHandling()) {
next({
code,
message: msg
});
} else {
res.status(code).send(_.escape(msg));
}
}
const clientKey = req.body.clientKey;
// Install / Uninstall hook should always be asymmetric (Excluding bitbucket apps)
if (isJWTAsymmetric(addon, req)) {
addon.authenticateAsymmetric()(req, res, err => {
if (err) {
sendError(err.message || "Error during asymmetric authentication.");
} else if (
/no-auth/.test(process.env.AC_OPTS) ||
(req.context && req.context.clientKey === clientKey)
) {
next();
} else {
sendError(
"clientKey in install payload did not match authenticated client"
);
}
});
} else if (addon.config.product().isBitbucket) {
// bitbucket apps uses legacy authentication using sharedSecret
addon.settings.get("clientInfo", clientKey).then(
settings => {
if (settings) {
addon.logger.info(
`Found existing settings for client ${clientKey}. Authenticating reinstall request`
);
addon.authenticate()(req, res, () => {
if (req.context.clientKey === clientKey) {
next();
} else {
sendError(
"clientKey in install payload did not match authenticated client"
);
}
});
} else {
next();
}
},
err => {
sendError(err.message);
}
);
} else {
sendError(
"Unexpected or missing JWT token, failed to verify installation."
);
}
};
}
function isJWTAsymmetric(addon, req) {
const token = utils.extractJwtFromRequest(addon, req);
if (!token) {
return false;
}
return jwt.AsymmetricAlgorithm.RS256 === jwt.getAlgorithm(token);
}
function getKey(keyId, hostBaseUrl) {
let keyServerUrl = CONNECT_INSTALL_KEYS_CDN_URL_PRODUCTION;
let fallbackKeyServerUrl = "";
if (hostBaseUrl) {
if (utils.isProductionHost(hostBaseUrl)) {
keyServerUrl = CONNECT_INSTALL_KEYS_ASAP_CDN_URL_PRODUCTION;
fallbackKeyServerUrl = CONNECT_INSTALL_KEYS_CDN_URL_PRODUCTION;
} else if (utils.isFedRAMPProductionHost(hostBaseUrl)) {
keyServerUrl = CONNECT_INSTALL_KEYS_ASAP_CDN_URL_FEDRAMP_PRODUCTION;
fallbackKeyServerUrl = CONNECT_INSTALL_KEYS_CDN_URL_FEDRAMP_PRODUCTION;
} else if (utils.isJiraDevBaseUrl(hostBaseUrl)) {
keyServerUrl = CONNECT_INSTALL_KEYS_ASAP_CDN_URL_STAGING;
fallbackKeyServerUrl = CONNECT_INSTALL_KEYS_CDN_URL_STAGING;
} else if (utils.isFedRAMPStagingHost(hostBaseUrl)) {
keyServerUrl = CONNECT_INSTALL_KEYS_ASAP_CDN_URL_FEDRAMP_STAGING;
fallbackKeyServerUrl = CONNECT_INSTALL_KEYS_CDN_URL_FEDRAMP_STAGING;
}
}
const cdnUrl = `${process.env.CONNECT_KEYS_CDN_URL || keyServerUrl}/${keyId}`;
return new Promise((resolve, reject) => {
request.get(cdnUrl, (_err, response) => {
if (_err || !response || !response.body) {
request.get(fallbackKeyServerUrl, (_err, response) => {
if (_err || !response || !response.body) {
return reject({
code: 404,
message: `Could not get public key with keyId ${keyId}`,
ctx: {}
});
}
resolve(response.body);
});
} else {
return resolve(response.body);
}
});
});
}
async function decodeAsymmetricToken(token, publicKey, verify) {
return jwt.decodeAsymmetric(
token,
publicKey,
jwt.AsymmetricAlgorithm.RS256,
!verify
);
}
async function verifyAsymmetricJwtAndGetClaims(addon, req) {
const token = utils.extractJwtFromRequest(addon, req);
if (!token) {
return Promise.reject({
code: 401,
message: "Could not find authentication data on request",
ctx: {
ctx: _.omit(req.body, ["sharedSecret", "publicKey"])
}
});
}
let unverifiedClaims;
const hostBaseUrl = _.get(req.body, "baseUrl");
const publicKey = await getKey(jwt.getKeyId(token), hostBaseUrl);
try {
unverifiedClaims = await decodeAsymmetricToken(token, publicKey, false);
} catch (e) {
return Promise.reject({
code: 401,
message: `Invalid JWT: ${e.message}`,
ctx: {}
});
}
const issuer = unverifiedClaims.iss;
if (!issuer) {
return Promise.reject({
code: 401,
message: "JWT claim did not contain the issuer (iss) claim",
ctx: {}
});
}
const allowedBaseUrls = _.compact([
addon.descriptor.baseUrl,
...addon.config.allowedBaseUrls()
]);
const refinedAllowedBaseUrls = _.map(allowedBaseUrls, url =>
url.replace(/\/$/, "")
);
if (
_.isEmpty(unverifiedClaims.aud) ||
!unverifiedClaims.aud[0] ||
!_.includes(
refinedAllowedBaseUrls,
unverifiedClaims.aud[0].replace(/\/$/, "")
)
) {
// audience should match the addon baseUrl defined in the descriptor
return Promise.reject({
code: 401,
message: "JWT claim did not contain the correct audience (aud) claim",
ctx: {}
});
}
const queryStringHash = unverifiedClaims.qsh;
if (!queryStringHash) {
// session JWT tokens don't require a qsh
return Promise.reject({
code: 401,
message: "JWT claim did not contain the query string hash (qsh) claim",
ctx: {}
});
}
const request = req;
const clientKey = issuer;
let verifiedClaims;
try {
verifiedClaims = await decodeAsymmetricToken(token, publicKey, true);
} catch (error) {
return Promise.reject({
code: 400,
message: `Unable to decode JWT token: ${error}`,
ctx: {}
});
}
const expiry = verifiedClaims.exp;
if (expiry && moment().utc().unix() >= expiry) {
return Promise.reject({
code: 401,
message: "Authentication request has expired. Try reloading the page.",
ctx: {}
});
}
if (!utils.validateQshFromRequest(verifiedClaims, request, addon)) {
return Promise.reject({
code: 401,
message: "Authentication failed: query hash does not match.",
ctx: {}
});
}
let settings;
try {
settings = await addon.settings.get("clientInfo", clientKey);
} catch (err) {
return Promise.reject({
code: 500,
message: `Could not lookup stored client data for ${clientKey}: ${err}`,
ctx: {}
});
}
const verifiedParams = {
clientKey,
hostBaseUrl: _.get(settings, "baseUrl", hostBaseUrl),
key: addon.key
};
// Use the context.user if it exists. This is deprecated as per
// https://ecosystem.atlassian.net/browse/AC-2424
if (verifiedClaims.context) {
verifiedParams.context = verifiedClaims.context;
const user = verifiedClaims.context.user;
if (user) {
if (user.accountId) {
verifiedParams.userAccountId = user.accountId;
}
if (user.userKey) {
verifiedParams.userKey = user.userKey;
}
}
}
if (!verifiedParams.userAccountId) {
// Otherwise use the sub claim, and assume it to be the AAID.
// It will not be the AAID if they haven't opted in / if its before
// the end of the deprecation period, but in that case context.user
// will be used instead.
verifiedParams.userAccountId = verifiedClaims.sub;
}
return verifiedParams;
}
function authenticateAsymmetric(addon) {
return function (req, res, next) {
function sendError({ code, message, ctx }) {
addon.logger.warn(
ctx,
`Authentication verification error (${code}): ${message}`
);
if (addon.config.expressErrorHandling()) {
next({
code,
message
});
} else {
res.format({
text() {
res.status(code).send(_.escape(message));
},
html() {
if (addon.config.errorTemplate()) {
res.statusCode = code;
res.render(addon.config.getErrorTemplateName(), {
...addon.config.getErrorTemplateObject(),
message
});
} else {
res.status(code).send(_.escape(message));
}
},
json() {
res.status(code).send({
message
});
}
});
}
}
if (/no-auth/.test(process.env.AC_OPTS)) {
console.warn(
"Auth verification is disabled, skipping validation of request."
);
next();
return;
}
verifyAsymmetricJwtAndGetClaims(addon, req)
.then(verifiedParams => {
const reqHandler = requestHandler(addon, verifiedParams || {});
reqHandler(req, res, next);
})
.catch(error => {
sendError(error);
});
};
}
module.exports = {
verify,
authenticateInstall,
authenticateAsymmetric
};