top of page
1-modra.jpg
ClsoeIT Logo

Validating and generating Atlassian JWT

When developing a add-on to cloud Jira, a usual need is to communicate with the Jira using REST API. The authentication is done using JWT and it took us a while to figure out how to validate and generate them. At the end of the day, it’s not a complicated process but there are a few things one can overlook and be stuck with the implementation. We will describe the complete implementation in this blog post.


We are using JavaScript as the backend language and this gives us the opportunity to use atlassian-jwt library that solves the JWT generation and validation. Another important library we will use is node-fetch or the proxy library cross-fetch (we use this one). The code snippets in the blog aren’t exactly structured as in our codebase. Some parts of code usually are brought together in order to make snippets more readable.


Installed JWT

When the plugin is installed, Jira will call the endpoint that you registered in the atlassian-connect.json as an Installed callback. You will usually want to use this callback to save the shared secret, that is passed to you by Jira. The shared secret is used for signing your JWT tokens or validating JWTs generated by Jira.


It’s also important to validate the JWT from the Installed callback itself. Let’s validate the algorithm first. We will get the signing algorithm of JWT from the JWT using atlassian-jwt library first. The algorithm is then checked against supported algorithms that are hardcoded in the plugin.

const atlassianJwt = require('atlassian-jwt');
const SUPPORTED_ALGORITHMS = new Set(["RS256","HS256","HS384", "HS512"]);

const signAlgorithm = atlassianJwt.getAlgorithm(rawJwt);

if (!SUPPORTED_ALGORITHMS.has(signAlgorithm)) {
  console.error("Algorithm %s from the header is not in the list of supported algorithms!", signAlgorithm);
  return;
}

Raw JWT is the raw string (for example eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.K1lVDxQYcBTPnWMTGeUa3gYAgdEhMFFv38VmOyl95bA) taken either from query parameters or from Authorization header. In the snippet below, the even object is the AWS lambda event (pretty much the request).

var rawJWT = null;

if (event && "queryStringParameters" in event && "jwt" in event.queryStringParameters) {
  rawJWT = event.queryStringParameters.jwt;
}

if(!rawJWT && event.headers && event.headers.authorization){
  rawJWT = event.headers.authorization.replace("JWT ","");
}

Validation of Installed callback is specific because it uses asymmetric JWT. It means, the Jira signed the JWT using a private key and you have to validate it using the public key. When the token is symmetric (all other callbacks except the Installed and Uninstalled), the JWT is signed and validated only using one key (shared secret).


We need to get the public key first using an id, that is named kid. It's in the JWT and we will use the Atlassian library to get it. We can then download the public key from the URL https://connect-install-keys.atlassian.com/kid using the fetch library.

const atlassianJwt = require('atlassian-jwt');
const fetch = require('cross-fetch');

const kid = atlassianJwt.getKeyId(rawJWT);
const publicKeyResponse = await fetch("https://connect-install-keys.atlassian.com/" + kid);
  
if (publicKeyResponse.status != 200) {
  console.error("JWT validation failed. Coudn't retrieve public key %s from the CDN.", kid);
  return;
}

const publicKey = await publicKeyResponse.text();

In the next step, the JWT is finally decoded and signature verified using the public key. We know someone manipulated with the JWT when the signature doesn’t match. In that case, the exception is thrown.

const atlassianJwt = require('atlassian-jwt');
var decodedJWT = null;

try {
  decodedJWT = atlassianJwt.decodeAsymmetric(rawJWT, publicKey, "RS256", false);
} catch (error) {
  console.error("Signature verification of JWT using public key %s failed.", publicKey);
  return;
}

We should also validate claims from the token. Claims are just the JSON payload of tokens. AUD should contain the URL of your plugin. It’s up to you how to get it. In our case, the URL is generated from the AWS API Gateway resource as an environmental variable in the AWS Cloudformation definition. The utility.getPluginURL just returns this variable.


Validating expiration is easy. You can use the moment library to get the current time and check if it’s after the EXP.


And the last check of Installed JWT - compare the hash of the target URL (URL to your Installed endpoint) against the QSH. We will use the Atlassian library to generate the hash. The event object is again the AWS lambda event - the request from Jira.

const moment = require('moment');
const atlassianJwt = require('atlassian-jwt');

const pluginUrl = utility.getPluginURL();

const aud = decodedJWT.aud;
if (aud[0] !== pluginUrl) {
  console.error("JWT claim aud invalid. Expected aud: %s, JWT aud: %s", pluginUrl, aud[0]);
  return;
}

const now = moment().utc();
const expired = moment.utc(decodedJWT.exp * 1000);
if (now.isAfter(expired)) {
  console.error("JWT expired!");
  return;
}

const targetUrl = event.rawPath + "?" + event.rawQueryString;
const request = atlassianJwt.fromMethodAndUrl(event.requestContext.http.method, targetUrl);
const generatedHash = atlassianJwt.createQueryStringHash(request);

if (decodedJWT.qsh !== generatedHash && decodedJWT.qsh !== 'context-qsh') {
  console.error("JWT claim qsh %s is not same as calculated qsh %s.", decodedJWT.qsh, generatedHash);
  return;
}

And that’s it. Now when we know the JWT is valid, we can save the shared secret, client id and cloud Jira base URL to the database. Keep in mind you cannot call the Jira in the Installed callback yet even if you already have the shared secret. The Jira simply won’t accept your requests because the installation of your plugin isn’t complete until you send response 200. If you need to call Jira during the installation, do it in the Enabled callback.


Uninstalled JWT

We will validate the token from the Uninstalled callback using the exact same process. There is only one difference - one additional claim to validate. It is the issuer - client id received in the installed callback.


You need to search the client id in your database using the base Jira URL. The Jira URL is usually in the request body under the name baseUrl or in the query parameters under the name xdm_e. I’m not putting here the code of method getClientIdByJiraBaseURL because it’s way too specific and implementation depends pretty much on the database you are using.

var baseUrlFromRequest = null;

if (event && "queryStringParameters" in event && "xdm_e" in event.queryStringParameters) {
  baseUrlFromRequest = event.queryStringParameters.xdm_e;
}

if (!baseUrlFromRequest && event.body){
  const requestBody = JSON.parse(event.body);
  if (requestBody.baseUrl) {
    baseUrlFromRequest = requestBody.baseUrl;
  }
}

const clientId = getClientIdByJiraBaseURL(baseUrlFromRequest);

if (decodedJWT.iss !== clientId) {
  console.error("JWT claim issuer invalid. Expected issuer: %s, JWT issuer: %s", clientId, decodedJWT.iss);
  return;
}

Validation of symmetric JWT

In all other cases (except the Installed and Uninstalled callback), the JWT from Jira has to be validated in a slightly different way. The code for algorithm and claims validation is the same, so I will skip it.


  1. Validate algorithm

  2. Search shared secret in your database using the client id or base Jira URL from the request

  3. Decode and validate the JWT using the code below

  4. Validate claims ISS, EXP and QSH


const atlassianJwt = require('atlassian-jwt');

const sharedSecret = getSharedSecretByJiraBaseURL(baseUrlFromRequest);
var decodedJWT = null;

try {
  decodedJWT = atlassianJwt.decodeSymmetric(rawJWT, sharedSecret, "HS256", false);
} catch (err) {
  console.error("JWT decoding error. Jira instance %s", baseUrlFromRequest);
  return;
}

Generation of JWT

In many cases, you will be the one who is going to initiate the communication with cloud Jira. To authenticate your plugin, you have to generate the token and send it in the Authorization header. First, you need to find the shared secret of the target cloud Jira instance you want to communicate with.


The payload will contain claims ISS, IAT, EXP and QSH. If claims are created correctly, Jira should accept your request.


  • ISS - issuer of the JWT. When we validated the JWT, the issuer was the client id but this is now different! When you generate the JWT, this has to be the app key of your plugin (the unique ID of your plugin that’s provided in the atlassian-connect.json). It’s easy to make a mistake here.

  • IAT - UTC time when the JWT was created

  • EXP - expiration time of your token. The JWT isn’t reusable because of QSH claim, so it doesn’t make sense to make a longed lived token. 3 minutes should be enough.

  • QSH - hash of the target URL, query parameters, and method of your request.

const moment = require('moment');
const atlassianJwt = require('atlassian-jwt');

const now = moment().utc();
const request = atlassianJwt.fromMethodAndUrl(method, requestUrl);

const tokenData = {
  "iss": "cz.closeit.atlassian.test-plugin",
  "iat": now.unix(),
  "exp": now.add(3, 'minutes').unix(),
  "qsh": atlassianJwt.createQueryStringHash(request)
};

const jwt = atlassianJwt.encodeSymmetric(tokenData, sharedSecret, "HS256");

And here is the last snippet that shows how to use the generated token. We will issue a request to persist a plugin property to the cloud Jira. The code above was wrapped to method generateTokenCustomSecret that takes the method, request URL, and shared secret.

const fetch = require('cross-fetch');

const appKey = "cz.closeit.atlassian.test-plugin";
const propertyKey = "test-property";
const sharedSecret = getSharedSecretByJiraBaseURL(baseJiraUrl);
requestUrl = baseJiraUrl + "/rest/atlassian-connect/1/addons/" + appKey + "/properties/" + propertyKey;

const jwtToken = await generateTokenCustomSecret("PUT", requestUrl, sharedSecret);
const propertyValue = "{ \"value\": \"test\" }";

const response = await fetch(requestUrl, {
    method: "PUT",
    headers: {
      "Accept": "application/json",
      "Content-Type": "application/json",
      "Authorization": "JWT " + jwtToken
    },
    body: propertyValue
  });

It was a lot of code, but we are finally able to authenticate our requests and also validate the Jira requests. If you want to send requests directly from the page (for example from React GUI), you can use the AP object (Connect JavaScript API) to do your requests. In that case, you don’t have to handle JWT at all. But that’s the whole other topic for another time.

Related Posts

See All

MGS integration with antivirus

One of the MGS features is to manage model-related files and documents. Of course, common and other model non-related files can be...

Flattening Docker images

Docker images are stored as layers. To be more precise, the filesystem is layered, so each change (RUN, COPY,…) will result in adding a...

Caching frontend web application

Web applications (or websites) are often these days rendered right on the user's computer. The server just provides the application and...

Comments


bottom of page