Securing a custom backend for Forge

Securing a custom backend for Forge
Photo by Cristina Gottardi / Unsplash

Connie AI is built on Atlassian's Forge developer platform. This is Atlassian's latest and greatest developer platform for apps to enhance the experience of Confluence, Jira, Jira Service Management, and I'm sure more to come.

The neat thing about Forge is that it comes with a hosted backend.

Forge apps are fully hosted by Atlassian. When you build a Forge app, Atlassian automatically creates your development, staging, and production environments. You can write a single function and it will run, significantly reducing the amount of code needed to write and operate an app.

This was designed to make operating your app easier and more secure, and to save you time and running costs.

Developer platforms like this that include limited hosting are incredibly powerful for simple or constrained applications. If you don't have to venture outside of the walled garden it can do nearly all of the heavy lifting to keep your application and its users safe and secure.

The drawback comes the moment you outgrow the limitations of the walled garden. For Connie AI, that was immediately, as our need for more sophisticated indexing and data processing meant that we would need our own backend. We will talk more about how Connie AI indexes and learns Confluence content in another post.

Atlassian has a nice table that highlights the complexity of knowing which party is responsible for securing which parts of a Forge application that they call the Shared responsibility model

Screenshot-2023-06-30-at-10.41.14-PM

Today we want to talk about the authentication row. For Connie AI, we have bi-directional communication that needs to be secured in order to be trusted:

Application (client) to custom backend

When you ask Connie AI a question, that question is sent to a Lambda function to be processed and answered. When the lambda function receives the request, how can it know who you are, what Confluence site you belong to, and what content you can see in a particular space? Because this request originates from the browser, there is nothing we could do solely there to create trust.

One option: We could request OAuth tokens for users and include the OAuth tokens with requests to the backend. The backend would then need to make a followup request using the token to an Atlassian API to verify that the token is valid and to load what access the user for the token has. There are a few downsides to this:

  1. OAuth tokens, even when scope limited, are highly sensitive. It feels a bit like sending your social security number back and forth to identify yourself. Sure it works, but there must be a better, less intrusive way.
  2. Increased latency. Before the lambda can start doing any meaningful work, it needs to wait on that roundtrip to the Atlassian API

Our choice: We can have Forge functions that execute on page load and as needed thereafter to cryptographically sign request payloads. By the time you've typed out your question and hit submit, we've loaded the ids of the content you have access to in the space and other contextual information we need and signed that information along with an expiration and given that to the client. Now when you press submit, the client sends the signed payload to the lambda function. The lambda function verifys the signature and the expiration (which critically is also signed), and if that all checks out, it can trust the payload.

We like this as nothing sensitive is being sent around and handled by the client, and no additional latency is added especially on the lambda side, where we are paying for compute time.

Custom backend to Forge (and then to Atlassian)

Occassionally, Connie AI needs to be able to communicate with the Confluence API from our own backend. A good example of this is resolving mention display names at indexing time. We were able to avoid having to store any OAuth tokens by using a Forge Web trigger. In this case we have a web trigger per Confluence site that we can call with a list of Atlassian accountIds. The web trigger will talk to the Confluence API to load the displayNames for the requested accounts, and return that to us.

Its perhaps a bit too easy to say that since the URL for the web trigger is itself a secret, you can trust any requests that it gets. We think this is a good start, but insufficient. We treat web trigger urls as secret, but we apply the same rigorous payload signing and verification techniques described above, with the only difference being that the payload is signed by our lambda with its secret, and verified by the web trigger function.

Signing & verifying payloads

The most straightforward way to do this is probably via an off the shelf JWT library for the language of your choice.

In the case of Connie AI we do this ourselves, in part to keep our dependencies as minimal as possible. If you don't feel comfortable doing it yourself, again, please use a well maintained JWT library.

Given a trusted payload here is what happens:

import { createHmac } from 'crypto';

export default function signPayload(payload) {
  const secret = process.env.HMAC_SECRET;
  if (!secret) {
    throw new Error('Need to define HMAC_SECRET');
  }
  const hasher = createHmac('sha256', secret);
  // expire payload in 10 minutes
  const serialized = JSON.stringify({ ...payload, exp: Date.now() + 600000 });
  const signature = hasher.update(serialized).digest('hex');
  return {
    payload: serialized,
    signature,
  };
}

On the receiving side we need to verify the signature and the expiration:

const { createHmac } = await import('node:crypto');

function isValidSignature(input: string, signature: string): boolean {
  if (!secret) {
    throw new Error('Need to define HMAC_SECRET');
  }
  const hasher = createHmac('sha256', secret);
  return hasher.update(input).digest('hex') === signature;
}

// [...]

// validate the signature
const isValid = isValidSignature(body.payload, body.signature);
const payload = JSON.parse(body.payload);
if (!isValid || !payload.exp) {
  return {
    ok: false,
    errResponse: {
      statusCode: 403,
      body: JSON.stringify({ error: 'Invalid signature' }),
    },
  };
}
if (Date.now() > payload.exp) {
  return {
    ok: false,
    errResponse: {
      statusCode: 403,
      body: JSON.stringify({ error: 'Signature expired' }),
    },
  };
}
// signature is valid and has not expired
// perform schema validation on payload [...]
return {
  ok: true,
  args: payload,
};
  
// [...]

Lastly, here is a helpful snippet to generate cryptographically random secrets that you could use:

const { randomBytes } = await import('node:crypto');
console.log(randomBytes(16).toString('hex'));

If you'd like to be notified of new posts and announcements from us about Connie AI and future products you can subscribe below: