If you have been using the Atlassian Tutorials, you may have noticed that most of the examples are in Java. Since Java plugins are becoming less common, a developer can use any stack they please. Recently, I needed JSON Web Token support for our node plugin, and wanted to share some of my lessons.

The packages that I utilized were:

Getting Started

Before implementing JWT authentication, you will need to modify your atlassian-connect.json file. There are three required changes:

  1. “authentication” attribute. Documentation for authentication
  2. “lifecycle” attribute. Documentation for lifecycle
  3. “scopes” attribute. Documentation for scopes

Also be sure to remember what your “key” attribute is. This will be needed for signing the tokens.

// atlassian-connect.json

{
  "name": "Hello World",
  "description": "Atlassian Connect add-on",
  "key": "com.example.myaddon",
  "baseUrl": "https://<my-addon-url>",
  "vendor": {
    "name": "Example, Inc.",
    "url": "http://example.com"
  },
  "authentication": {
    "type": "jwt"
  },
  "apiVersion": 1,

  "lifecycle": {
    "installed": "/installed",
    "uninstalled": "/uninstalled",
    "enabled": "/enabled",
    "disabled": "/disabled"
  },
  "scopes": ["read", "write"],
  "modules": {
    "generalPages": [{
      "url": "/helloworld.html",
      "key": "hello-world",
      "location": "system.top.navigation.bar",
      "name": {
        "value": "Greeting"
      }
    }]
  }
}

Adding Body Parser

It is likely that you are already using Body Parser. If not, you will need to install it for reading JSON payloads. Atlassian’s API responses will be in JSON.

var bodyParser = require('body-parser');
var express = require('express');
var app = express();
app.use(bodyParser.json());

Creating the Model Objects

To sign and verify the tokens, we will need to first setup our webhooks. These webhooks receive the shared secret, client key, public key, and base url. We are going to store these in a model called Organization and must be stored in a database.

Here is the base model that we are utilizing for our models:

// models/base.js

var repo = require('../services/repo');
var _ = require('underscore');

var method = Base.prototype;

function Base(params) {
  if (_.isUndefined(this.attributes)) {
    throw new Error("Base.constructor: this.attributes must be defined.");
  }

  if (_.isUndefined(this.required)) {
    throw new Error("Base.constructor: this.required must be defined.");
  }

  if (!_.isUndefined(params)) {
    _.each(this.attributes, function(e) {
      this[e] = params[e];
    }.bind(this));
  }
}

method.checkRequired = function() {
  _.each(this.required, function(key) {
    if (_.isUndefined(this[key]) || _.isNull(this[key])) {
      throw new Error("Base.checkRequired: attribute " + key + " must be defined.");
    }
  }.bind(this));
}

method.insert = function() {
  this.checkRequired();

  var model_params = _.reduce(this.attributes, function(acc, e) {
    if (!_.isUndefined(this[e]) && !_.isNull(this[e])) {
      acc[e] = this[e];
    }
    return acc;
  }.bind(this), {});

  if (_.isEmpty(model_params)) {
    throw new Error("Base.insert: parameters must be provided. Model given: ", this);
  }

  var now = new Date();
  model_params.updated_at = now;
  model_params.inserted_at = now;

  return repo.query('INSERT into `' + this.table + '` SET ?', model_params);
}

//method.fetchOne implemented, but not shown
//method.update implemented, but not shown
//method.fetchAll implemented, but not shown
//method.remove implemented, but not shown

module.exports = Base;

Now we can create our Organization model and extend from our base file.

// models/organization.js

var _ = require("underscore"),
    repo = require('../services/repo'),
    _super = require("./base").prototype,
    method = Organization.prototype = Object.create(_super);

method.constructor = Organization;

function Organization(params) {
  this.table = "organization";
  this.attributes = ["organization_id", "client_key", "public_key", "shared_secret",
                     "server_version", "plugins_version", "base_url", "deleted_at",
                     "inserted_at", "updated_at"
                    ];

  this.required = ["client_key", "public_key", "shared_secret", "base_url"];

  _super.constructor.apply(this, [params]);

  this.fetchByHost = function(base_url) {
    return repo.query('SELECT * FROM '+ this.table +' WHERE base_url=? LIMIT 1;', [base_url]);
  }
}

module.exports = Organization;

Setting Up the /installed and /uninstalled Webhooks

Now add the /installed route, and handle the information from Jira.

// index.js

/**
 * A webhook that is executed when an organization
 * installs this plugin on their instance.
 */
app.post('/installed', function(req, res) {
  var new_client = new Organization({
    client_key: req.body.clientKey,
    public_key: req.body.publicKey,
    shared_secret: req.body.sharedSecret,
    server_version: req.body.serverVersion,
    plugins_version: req.body.pluginsVersion,
    base_url: req.body.baseUrl
  });

  console.log("Your client key is: " + new_client.client_key);
  console.log("Your secret key is: " + new_client.shared_secret);
  console.log("Your base url is:", new_client.base_url);
  console.log("Your public key is:", new_client.public_key);

  /**
   * Adapt this line to your datastore needs.
   * Be sure to still respond with a 204!
   **/
  new_client.insert().then(function(db_results) {
    res.status(204).send();
  }).catch(function(err) {
    console.log("{err}: ", err);
    res.status(500).send();
  });
});

Take a small break and test the plugin by registering for a developer instance on Atlassian Cloud and then to try installing your plugin by hosting it with ngrok.

Jira expects a response from the /installed webhook, otherwise it will fail to install. Here we are doing res.status(204).send();. If the plugin is not installing, it may be because the node server is throwing an error before it can get to this point.

Creating a JWT Token

Creating a token is a lot more work than verifying, but don’t worry! We can get through this. If you are unfamiliar with Atlassian’s JWT process, please look at that first.

A visual represntation of the process

The process for creating a token is:

  1. Validate the parameters.
  2. Convert our object of parameters into a string sorted by keys key1=value1&key2=value2 if this is a GET request.
  3. Concatenate the request type, canonical url, and the serialized request with “&”. (POST&/rest/api/latest/search&).
  4. Calculate the qsh hash of the result from step #3.
  5. Add the qsh hash to our JWT claims.
  6. Sign the token with the jsonwebtoken library.
// lib/jwt.js


var jwt = require('jsonwebtoken');
var base64url = require('base64url');
var _ = require('underscore');
var crypto = require('crypto');
var jwt_auth = {};

/**
 * Verifies a token from Atlassian given their generated token and the shared secret.
 * @param {string} token the token provided by Atlassian.
 * @param {string} secret This is the shared secret that is pulled out by the middleware and placed in the req.jwt object. If the secret does not exist, it is because you need to figure out a way to render the host object in a form or url query parameter.
 *
 */
jwt_auth.verify = function(token, secret) {
  if(!_.isString(token)) {
    throw new Error("jwt.verify: token must be a string.");
  }

  if(!_.isString(secret)) {
    throw new Error("jwt.verify: secret must be a string.");
  }

  var success;
  try {
    jwt.verify(token, secret);
    success = true;
  } catch(err) {
    success = false;
  }
  return success;
}

/**
 * This will create a JWT token that is compliant with Atlassian's shit.
 * @param {string} key This can be found in your atlassian-connect.json file.
 * @param {string} secret This is the shared secret that is pulled out by the middleware and placed in the req.jwt object. If the secret does not exist, it is because you need to figure out a way to render the host object in a form or url query parameter.
 * @param {string} type This will either be GET, POST, PUT, or DELETE
 * @param {string} canonical This is the canonical url. This is the entire URL MINUS the base_url in the req.jwt.base_url.
 * @param {object} params This is the parameters for a certain request. ONLY USE THIS FOR A GET REQUEST. This is because POST requests are inherently safe due to how the request is made over https. GET requests are unsafe because the url is not encrypted.
 *
 */
jwt_auth.encode = function(key, secret, type, canonical, params) {
  if (!_.isString(key)) {
    throw new Error("jwt.encode: key must be a string");
  }

  if (!_.isString(secret)) {
    throw new Error("jwt.encode: secret must be a string");
  }

  if (!_.isString(type)) {
    throw new Error("jwt.encode: type must be a string");
  }

  if (!_.isString(canonical)) {
    throw new Error("jwt.encode: canonical must be a string. Found ", canonical);
  }

  if (!_.isNull(params) && !_.isUndefined(params) && !_.isObject(params)) {
    throw new Error("jwt.encode: params must be undefined or an object");
  }

  requestArray = [type, canonical];
  if (type === "GET" || type === "get") {
    requestArray.push(jwt_auth.constructQueryString(params));
  } else {
    requestArray.push("");  
  }

  var requestString = requestArray.join('&');

  var claims = {
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000)+86400, //expires in 1 day
    iss: key,
    qsh: crypto.createHash('sha256').update(requestString).digest('hex')
  };

  return jwt.sign(claims, secret);
}

jwt_auth.constructQueryString = function(params) {
  if (_.isEmpty(params)) {
    return "";
  }

  return _.chain(params).map(function(v, k) {
    return [k, v];
  }).reduce(function(acc, e) {
    if (e[0] == "jwt") {
      return acc;   
    }
    acc.push({key: e[0], value: e[1]});
    return acc;  
  }, []).sortBy('key').reduce(function(acc, e) {
    var val;
    if (_.isArray(e.value)) {
      var formattedArray = _.map(e.value, function(f) {
        return encodeURIComponent(f);
      });
      val = formattedArray.join(',');
    } else {
      val = encodeURIComponent(e.value);
    }

    acc.push(encodeURIComponent(e.key) + '=' + val);
    return acc;
  }, []).value().join('&');
}

module.exports = jwt_auth;

Making The Request

Now we will create a wrapper around the request library. This will set the proper HTTP headers and interact with the JWT token. You can use this as a guide on how to construct a request that Jira will understand.

// services/request.js

var request = {};
var requestLib = require('request');
var http = require('http');
var _ = require('underscore');
var authentication = require('./simpleauthentication');
var jwt = require('../lib/jwt');
var querystring = require('querystring');
var Promise = require('promise');
var moment = require('moment');
requestLib.debug = true;

/**
 * Submits a GET request
 * @param {string} resource The name of the resource that is going to be fetched.
 * @param {string} id The id of the resource that is going to be fetched.
 * @return {Promise} Returns the results of the request.
 */
request.getRequest = function(req, resource, id, query) {
  if (_.isUndefined(resource)) {
    throw new Error("request.getRequest: resource must be defined.");
  }

  if (!_.isUndefined(query) && !_.isNull(query) && !_.isObject(query)) {
    throw new Error("request.getRequest: query must be defined.");
  }

  var canonical = '/rest/api/latest/' + resource;
  if (id) {
    canonical += '/' + id;
  }

  var now = moment().utc();

  //TODO: You will need to change the com.example.myaddon to your atlassian key.
  var token = jwt.encode("com.example.myaddon", req.jwt.shared_secret, "GET", canonical, query);
  var url = req.jwt.base_url + canonical;

  url += "?jwt=" + token;
  if (query) {
    url = url + '&' + querystring.stringify(query);
  }

  var options = {
    url: url,
    method: 'GET',
    headers: {
      'Authorization': 'JWT ' + token,
      "Accept": "application/json"
    }
  };

  return new Promise(function(resolve, reject) {
    requestLib(options, function(error, response, body) {
      if (error) {
        return reject(error);
      }
      return resolve(body);
    });
  });
};


/**
 * Submits a POST request
 * @param {string} resource The name of the resource that is going to be fetched.
 * @param {Object} postdata The data that will be sent in the POST request
 * @param {string} id The id of the resource that is going to be fetched/modified
 * @return {Promise} Returns the results of the request
 */
request.postRequest = function(req, resource, postdata, id) {
  if (!_.isString(resource)) {
    throw new Error("request.postRequest: resource must be a string. Found ", resource);
  }

  if (_.isUndefined(postdata) || _.isNull(postdata)) {
    throw new Error("request.getPostOptions: postdata must be defined.");
  }

  var canonical = '/rest/api/latest/' + resource;
  if (id) {
    canonical += '/' + id;
  }

  var url = req.jwt.base_url + canonical;

  //TODO: You will need to change the com.example.myaddon to your atlassian key.
  var token = jwt.encode("com.example.myaddon", req.jwt.shared_secret, "POST", canonical, postdata);

  var options = {
    url: url,
    method: 'POST',
    json: postdata, // Notice that we are not doing JSON.stringify(). This results in a 401 Unauthorized for some reason.
    headers: {
      'Authorization': 'JWT ' + token,
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    }
  };
  return new Promise(function(resolve, reject) {
    requestLib(options, function(error, response, body) {
      if (error) {
        return reject(error);
      }
      return resolve(body);
    });
  });
};


module.exports = request;

Final Thoughts

Phew! What a process for placing an HTTP Header of Authorization: Basic mycredential. Hopefully you have gotten all of your JWT needs straightened out. If not, feel free hit me up on Twitter with any questions. You can also email me at jake@rokkincat.com.