Skip to main content

Verifying Signed Requests

Some LifeOmic systems can be configured to perform external requests to your API server to extend behavior.

In general, these external requests are cryptographically signed, to allow your API server to verify the authenticity of the request.

For each LifeOmic system that performs requests like this, a public key will be available in the form of a JWKS url. Refer to the documentation on the relevant subsystem for that URL.

Validating Requests

To validate requests, you'll need a basic understanding of JSON Web Tokens (JWTs) and JSON Web Key Sets (JWKS). These concepts are documented thoroughly at jwt.io.

Decode the JWT signature header

First, extract the LifeOmic-Signature header from the incoming request. The value of this header is a JWT.

Using any JWT library, decode the JWT to extract the JWT header, which will look something like this:

{
"alg": "RS256",
"kid": "365ee4e9-c4b2-4892-abd9-7b0b2cd9f8f8",
"typ": "JWT"
}

The alg value should be RS256. Reject the request if it is not.

You'll need a reference to the kid value in the next step.

Fetch the public key

Using a JWKS client, fetch the public JWK matching this kid from the subsystem's specified JWKS url. The fetched key should look something like this:

{
// This value must match the `kid` from the JWT
"kid": "365ee4e9-c4b2-4892-abd9-7b0b2cd9f8f8",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "<a very long value>",
"e": "AQAB"
}

If there is no key with a matching kid value, reject the request.

Validate the token

Using a JWT library, verify the signature of the JWT against the fetched JWK. If it does not match, reject the request.

In addition, you should perform some validation against the JWT payload. The JWT payload should look something like this:

{
"method": "POST",
"url": "https://<your_custom_url>",
"body_sha256": "fHgEn13brm7g4jF5jT5EaZF8ZY8twhCXBl0Kw9yUZgA=",
"iat": 1657832536
}

Verify that the method and url match the expected values -- the method will be defined by the LifeOmic subsystem, and the url will be the custom value you configured to receive requests, plus any query parameters provided by the subsystem. Compare these values against the actual received request, and reject the request if either do not match.

If the request has a body, the JWT payload will have a body_sha256 value which should be validated to confirm the authenticity of the body. To confirm this, stringify the JSON request body (using no extra spacing), and compute the SHA256 hash of the output. If this hash does not match the body_sha256 value, reject the request.

The iat represents the Unix time (in seconds) at which we sent the request -- you can compare this to the current time, and decide whether it is in within a tolerable range to protect against replay attacks.

Example Implementations

import * as crypto from 'crypto';
import express from 'express';
import bodyParser from 'body-parser';
import jwksClient from 'jwks-rsa'
import * as JWT from 'jsonwebtoken'

const getPublicPem = async (keyId: string) => {
const client = jwksClient({
jwksUri: '<the subsystem jwks url>'
});
const key = await client.getSigningKey(keyId);
const publicKeyPem = key.getPublicKey();
return publicKeyPem;
};

express()
.use(bodyParser.json())
.post('/your-external-route', async (req, res) => {
const signature = req.header('LifeOmic-Signature')
if (!signature) {
throw new Error('No signature header');
}

// Decode the JWT
const decoded = JWT.decode(signature, { complete: true });
if (!decoded) {
throw new Error('Failed to decode JWT');
}

// Fetch the public key
const publicPem = await getWebhooksPublicPem(decoded.header.kid);

// Verify the signature
JWT.verify(signature, publicPem)

// Validate against the payload
if (decoded.payload.method !== 'POST') {
throw new Error('Method does not match signed payload');
}

if (decoded.payload.url !== `<base url>${req.originalUrl}`) {
throw new Error('URL does not match signed payload');
}

const receivedBodySHA256 = crypto
.createHash('sha256')
.update(JSON.stringify(req.body))
.digest('base64')

if (decoded.payload.body_sha256 !== receivedBodySHA256) {
throw new Error('Body does not match signed payload')
}

// Validate the current time against the iat. We'll use a 5 minute
// tolerance limit.
const nowInSeconds = Date.now() / 1000;
const timeSinceIatInSeconds = nowInSeconds - decoded.payload.iat;
if (timeSinceIatInSeconds > 300) { // 5min = 60s * 5 = 300s
throw new Error('Too long since iat value. Is this a replay attack?')
}

// Now, the request is verified. Perform your custom logic!

res.status(200).end();
})
.listen(port, () => {
console.log(`Server is running on port ${port}`);
});