atlassian-connect-express
Version:
Library for building Atlassian Add-ons on top of Express
282 lines (251 loc) • 7.08 kB
JavaScript
const moment = require("moment");
const jwt = require("atlassian-jwt");
const requestHandler = require("./request");
const _ = require("lodash");
const utils = require("../internal/utils");
const TOKEN_KEY_PARAM = "acpt";
const TOKEN_KEY_HEADER = `X-${TOKEN_KEY_PARAM}`;
function authenticateWebhook(addon) {
return function (req, res, next) {
addon.emit("webhook_auth_verification_triggered");
authenticate(addon)(req, res, err => {
if (err) {
return next(err);
} else {
addon.emit("webhook_auth_verification_successful");
return next();
}
});
};
}
// Create a JWT token that can be used instead of a session cookie
function createSessionToken(addon, verifiedClaims, clientKey, settings) {
const now = moment().utc();
const baseJwt = {
iss: settings.key || addon.key,
iat: now.unix(),
sub: verifiedClaims.sub,
exp: now.add(addon.config.maxTokenAge(), "milliseconds").unix(),
aud: [clientKey]
};
// If the context.user exists, then send that too. This is to handle
// the interim period swapover from userKey to userAccountId.
if (verifiedClaims.context) {
baseJwt.context = verifiedClaims.context;
}
return jwt.encodeSymmetric(
baseJwt,
settings.sharedSecret,
jwt.SymmetricAlgorithm.HS256
);
}
async function getVerifiedClaims(addon, req, res, skipQshVerification) {
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;
try {
unverifiedClaims = jwt.decodeSymmetric(
token,
"",
jwt.SymmetricAlgorithm.HS256,
true
); // decodeSymmetric without verification;
} 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 queryStringHash = unverifiedClaims.qsh;
if (!queryStringHash && !skipQshVerification) {
// 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;
let clientKey = issuer;
// The audience claim identifies the intended recipient, according to the JWT spec,
// but we still allow the issuer to be used if 'aud' is missing.
// Session JWTs make use of this (the issuer is the add-on in this case)
if (!_.isEmpty(unverifiedClaims.aud)) {
clientKey = unverifiedClaims.aud[0];
}
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: {}
});
}
if (!settings) {
return Promise.reject({
code: 401,
message: `Could not find stored client data for ${clientKey}. Is this client registered?`,
ctx: {}
});
}
const secret = settings.sharedSecret;
if (!secret) {
return Promise.reject({
code: 401,
message: `Could not find JWT sharedSecret in stored client data for ${clientKey}`,
ctx: {}
});
}
let verifiedClaims;
try {
verifiedClaims = jwt.decodeSymmetric(
token,
secret,
jwt.SymmetricAlgorithm.HS256,
false
);
} catch (error) {
return Promise.reject({
code: 400,
message: `Unable to decodeSymmetric JWT token: ${error}`,
ctx: {}
});
}
const expiry = verifiedClaims.exp;
// todo build in leeway?
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,
skipQshVerification
)
) {
return Promise.reject({
code: 401,
message: "Authentication failed: query hash does not match.",
ctx: {}
});
}
const sessionToken = createSessionToken(
addon,
verifiedClaims,
clientKey,
settings
);
res.setHeader(TOKEN_KEY_HEADER, sessionToken);
// Invoke the request middleware (again) with the verified and trusted parameters
// Base params
const verifiedParams = {
clientKey,
hostBaseUrl: settings.baseUrl,
token: sessionToken,
key: settings.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 authenticate(addon, skipQshVerification) {
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;
}
getVerifiedClaims(addon, req, res, skipQshVerification)
.then(verifiedParams => {
const reqHandler = requestHandler(addon, verifiedParams);
reqHandler(req, res, next);
})
.catch(error => {
sendError(error);
});
};
}
module.exports = {
authenticate,
authenticateWebhook,
getVerifiedClaims
};