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
- NodeJS
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}`);
});