vhook-js
Version:
Verifyable Webhooks
476 lines (406 loc) • 16 kB
JavaScript
// vhook.js
const http = require('http');
const https = require('https');
const { URL } = require('url');
const jwt = require('jsonwebtoken');
const jose = require('node-jose');
const { v4: uuidv4 } = require('uuid');
/**
* Generates a key pair for use with VHooks, supporting customizable algorithms and parameters.
* Uses human-friendly parameter names.
* @param {Object} [options] - Options for key generation.
* @param {string} [options.key_type='RSA'] - Key type ('RSA', 'EC', 'oct').
* @param {number} [options.key_size=2048] - Key size in bits (for RSA and oct keys).
* @param {string} [options.curve='P-256'] - Curve name (for EC keys, e.g., 'P-256', 'P-384', 'P-521').
* @param {string} [options.algorithm='RS256'] - The algorithm intended for use with the key.
* @param {string} [options.usage='sig'] - The intended use of the key ('sig' for signature, 'enc' for encryption).
* @param {string} [options.key_id] - Key ID, a unique identifier for the key.
* @returns {Promise<Object>} - An object containing keys in JWK and PEM formats.
* @throws {Error} - If required fields are missing or conflicting attributes are defined.
*/
async function create_vhook_keypair(options = {}) {
if (typeof options !== 'object' || options === null) {
throw new Error('Options must be a non-null object.');
}
// Mapping of human-friendly names to internal names
const fieldMapping = {
key_type: 'kty',
key_size: 'modulusLength',
curve: 'crv',
algorithm: 'alg',
usage: 'use',
key_id: 'kid',
};
// Default values
const defaults = {
kty: 'RSA',
modulusLength: 2048,
crv: 'P-256',
alg: 'RS256',
use: 'sig',
// kid is optional
};
// Prepare parameters
const params = {};
// Handle conflicts and map fields
for (const [friendlyName, internalName] of Object.entries(fieldMapping)) {
const friendlyValue = options[friendlyName];
const internalValue = options[internalName];
if (friendlyValue !== undefined && internalValue !== undefined) {
throw new Error(
`Conflicting options: both '${friendlyName}' and '${internalName}' are defined. Please use only one.`
);
}
let value = friendlyValue !== undefined ? friendlyValue : internalValue;
if (value !== undefined) {
params[internalName] = value;
} else if (defaults[internalName] !== undefined) {
params[internalName] = defaults[internalName];
}
}
// Add any other options that are not in the mapping
const otherOptions = { ...options };
for (const key of Object.keys(fieldMapping)) {
delete otherOptions[key];
}
// Merge other options into params
Object.assign(params, otherOptions);
const { kty, modulusLength, crv, alg, use, kid, ...additionalOptions } = params;
// Create a key store
const keyStore = jose.JWK.createKeyStore();
// Prepare parameters for key generation
const genParams = {
alg,
use,
kid,
...additionalOptions,
};
let key;
if (kty === 'RSA') {
// Generate RSA key pair
key = await keyStore.generate(kty, modulusLength, genParams);
} else if (kty === 'EC') {
// Generate EC key pair
if (!crv) {
throw new Error("Curve ('curve' or 'crv') must be specified for EC keys.");
}
key = await keyStore.generate(kty, crv, genParams);
} else if (kty === 'oct') {
// Generate symmetric key
key = await keyStore.generate(kty, modulusLength, genParams);
} else {
throw new Error(`Unsupported key type: ${kty}`);
}
let result;
if (kty === 'oct') {
result = {
key: {
jwk: key.toJSON(true),
},
};
// Ensure 'use' and 'alg' are included
result.key.jwk.use = use;
result.key.jwk.alg = alg;
} else {
// Export keys in JWK and PEM formats
const privateKeyJWK = key.toJSON(true);
const publicKeyJWK = key.toJSON();
const privateKeyPEM = key.toPEM(true);
const publicKeyPEM = key.toPEM();
result = {
privateKey: {
jwk: privateKeyJWK,
pem: privateKeyPEM,
},
publicKey: {
jwk: publicKeyJWK,
pem: publicKeyPEM,
},
};
}
return result;
}
/**
* Creates a VHook (signed JWT) using the provided payload and private key.
* @param {Object} options - The VHook payload.
* @param {string|Object} privateKey - The private key in PEM format or JWK object.
* @returns {string} - The signed JWT (VHook).
*/
/**
* Creates a VHook (signed JWT) using the provided options and private key.
*
* @param {Object} options - The input parameters for the VHook payload.
* @property {string} options.issuer - The issuer of the VHook (mapped to 'iss'). **Required.**
* @property {string} options.audience - The intended audience of the VHook (mapped to 'aud'). **Required.**
* @property {string} options.origin - The origin of the event (e.g., 'customer', 'order'). **Required.**
* @property {string} options.event - The type of event (e.g., 'customer.created'). **Required.**
* @property {Object} options.data - The event data payload. **Required.**
* @property {string} [options.message_id] - A unique message ID (mapped to 'jti'). If not provided, a UUID will be generated.
* @property {Date|number} [options.expiration] - Expiration time as a `Date` object, UNIX timestamp, or seconds offset from now. If not provided, defaults to 60 seconds from now.
* @property {Date|number} [options.issued_at] - Issued-at time as a `Date` object, UNIX timestamp, or seconds offset from now. Defaults to current time.
* @property {Date|number} [options.not_before] - Not-before time as a `Date` object, UNIX timestamp, or seconds offset from now.
* @property {string} [options.subject] - The subject of the VHook (mapped to 'sub').
* @property {string} [options.algorithm='RS256'] - The signing algorithm to use (e.g., 'RS256', 'ES256').
* @param {string|Object} privateKey - The private key in PEM format or JWK object used to sign the VHook.
* @returns {string} - The signed JWT (VHook) as a string.
* @throws {Error} - If required fields are missing or if there are conflicts in the options.
*/
function create_vhook(options, privateKey) {
const jwtPayload = prepare_vhook_payload(options);
let jwtOptions = {
algorithm: options.algorithm || 'RS256',
}
// Sign the JWT
const vhook = jwt.sign(jwtPayload, privateKey, jwtOptions);
return vhook;
}
/**
* Decodes and verifies a VHook (JWT) using the provided public key and verification options.
* It returns the extracted payload and includes the raw token payload.
*
* @param {string} vhook - The VHook token (JWT string).
* @param {string|Object} publicKey - The public key in PEM format or JWK object used to verify the VHook.
* @param {Object} [jwtVerifyOptions] - Optional override verification options for `jsonwebtoken.verify`.
* @property {Array<string>} [jwtVerifyOptions.algorithms] - List of allowed algorithms (e.g., ['RS256', 'ES256']).
* @property {string} [jwtVerifyOptions.audience] - Expected audience (`aud`) claim value.
* @property {string} [jwtVerifyOptions.issuer] - Expected issuer (`iss`) claim value.
* @property {boolean} [jwtVerifyOptions.ignoreExpiration=false] - Whether to ignore the `exp` claim.
* @property {boolean} [jwtVerifyOptions.ignoreNotBefore=false] - Whether to ignore the `nbf` claim.
* - Other options as per the `jsonwebtoken` library can also be included.
* @returns {Object} - The decoded and extracted payload.
* @property {Object} result - The extracted payload.
* @property {Object} result.raw_token - The original raw JWT payload from the JWT.
* @throws {Error} - If verification fails.
*/
function decode_vhook(vhook, publicKey, jwt_verify_options) {
const payload = jwt.verify(vhook, publicKey, jwt_verify_options);
const result = extract_vhook_payload(payload);
result.raw_token = payload;
return result;
}
/**
* Decodes a VHook (JWT) without verification.
* It returns the extracted payload and includes the raw token payload.
* NOTE THAT THIS FUNCTION DOES NO VERIFICATION, SO MAY RETURN TAMPERED DATA!
* It is provided for the rare cases where you need to see the contents of a vhook before
* verification takes place, for example if you receive vhooks from multiple origins on the
* same endpoint and need to know which public key you need to load for verification.
*
* @param {string} vhook - The VHook token (JWT string).
* @returns {Object} - The decoded and extracted payload.
* @property {Object} result - The extracted payload.
* @property {Object} result.raw_token - The original raw JWT payload from the JWT.
* @throws {Error} - If decoding fails.
*/
/**
* Decodes a VHook without verifying the signature.
* @param {string} vhook - The VHook token (JWT).
* @returns {Object} - The decoded payload.
*/
function decode_vhook_without_validation(vhook) {
const payload = jwt.decode(vhook);
payload.unverified = true;
const result = extract_vhook_payload(payload);
result.raw_token = payload;
return result;
}
// Helper function to process date fields
function processDateField(value) {
if (value instanceof Date) {
return Math.floor(value.getTime() / 1000);
} else if (typeof value === 'number') {
if (value < 86400) {
// Treat as offset from now in seconds
return Math.floor(Date.now() / 1000) + value;
} else {
// Use as-is (assumed to be a timestamp)
return value;
}
} else {
throw new Error('Invalid date value. Must be a Date object or a number.');
}
}
/**
* Prepares the VHook payload by mapping human-friendly parameter names to JWT field names.
* @param {Object} params - The input parameters containing payload data.
* @returns {Object} - The prepared payload with JWT field names.
* @throws {Error} - If required fields are missing or conflicting attributes are defined.
*/
function prepare_vhook_payload(params) {
if (typeof params !== 'object' || params === null) {
throw new Error('Parameters must be a non-null object.');
}
// Mapping of human-friendly names to JWT field names
const fieldMapping = {
issuer: 'iss',
audience: 'aud',
origin: 'origin',
data: 'data',
event: 'event',
expiration: 'exp',
message_id: 'jti',
subject: 'sub',
not_before: 'nbf',
issued_at: 'iat',
};
const requiredFields = ['iss', 'aud', 'origin', 'data', 'event', 'jti'];
const payload = {};
// Check for conflicting attributes and map fields
for (const [friendlyName, jwtName] of Object.entries(fieldMapping)) {
const friendlyValue = params[friendlyName];
const jwtValue = params[jwtName];
if (friendlyName != jwtName && friendlyValue !== undefined && jwtValue !== undefined) {
throw new Error(
`Conflicting attributes: both '${friendlyName}' and '${jwtName}' are defined. Please use only one.`
);
}
let value = friendlyValue !== undefined ? friendlyValue : jwtValue;
if (value !== undefined) {
// Handle date fields
if (['exp', 'expiration', 'iat', 'issued_at', 'nbf', 'not_before'].includes(friendlyName) || ['exp', 'iat', 'nbf'].includes(jwtName)) {
value = processDateField(value);
}
payload[jwtName] = value;
}
}
// Set default expiration to 60 seconds from now if not set
if (payload.exp === undefined) {
payload.exp = Math.floor(Date.now() / 1000) + 60;
}
if (typeof payload.jti == 'undefined') {
payload.jti = uuidv4();
}
// Ensure required fields are present
for (const field of requiredFields) {
if (payload[field] === undefined) {
throw new Error(`Missing required field: '${fieldMapping[field] || field}'.`);
}
}
return payload;
}
/**
* Extracts the VHook payload by mapping JWT field names to human-friendly parameter names.
* It also converts timestamp fields into Date objects.
* @param {Object} payload - The decoded JWT payload.
* @returns {Object} - The extracted payload with human-friendly field names.
*/
function extract_vhook_payload(payload) {
if (typeof payload !== 'object' || payload === null) {
throw new Error('Payload must be a non-null object.');
}
// Mapping of JWT field names to human-friendly names
const fieldMapping = {
iss: 'issuer',
aud: 'audience',
origin: 'origin',
data: 'data',
event: 'event',
exp: 'expiration',
jti: 'message_id',
sub: 'subject',
nbf: 'not_before',
iat: 'issued_at',
};
const extracted = {};
for (const [jwtName, friendlyName] of Object.entries(fieldMapping)) {
if (payload[jwtName] !== undefined) {
let value = payload[jwtName];
// Handle date fields
if (['exp', 'iat', 'nbf'].includes(jwtName)) {
value = new Date(value * 1000); // Convert UNIX timestamp to Date object
}
extracted[friendlyName] = value;
}
}
// Include any other fields that are not in the mapping
for (const key of Object.keys(payload)) {
if (!fieldMapping.hasOwnProperty(key)) {
extracted[key] = payload[key];
}
}
return extracted;
}
/**
* Sends a VHook token to a specified URL via POST request.
* @param {string} vhook - The VHook token (JWT string).
* @param {string} url - The endpoint URL to send the VHook to.
* @param {Object} [options] - Optional parameters.
* @param {boolean} [options.fireAndForget=false] - If true, returns immediately without waiting for the response.
* @returns {Promise<Object|undefined>} - If not in fire-and-forget mode, returns a promise that resolves to an object containing status code, headers, and response body. If in fire-and-forget mode, returns undefined.
*/
function send_vhook(vhook, url, options = {}) {
const { fireAndForget = false } = options;
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const postData = JSON.stringify({ vhook });
const requestOptions = {
method: 'POST',
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname + parsedUrl.search,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
};
if (fireAndForget) {
// Send request on next tick and return immediately
setImmediate(() => {
const req = protocol.request(requestOptions);
req.on('error', (e) => {
// Optionally handle errors (e.g., log them)
console.error('Error sending VHook:', e);
});
req.write(postData);
req.end();
});
// Return undefined for fire-and-forget mode
return;
} else {
// Return a promise that resolves when the response is received
return new Promise((resolve, reject) => {
const req = protocol.request(requestOptions, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
let responseBody = data;
const contentType = res.headers['content-type'] || '';
if (contentType.includes('application/json')) {
try {
responseBody = JSON.parse(data);
} catch (err) {
// If JSON parsing fails, return the raw data
responseBody = {
'status': 'failed',
'message': 'unparsable vhook response',
'raw_response': data
}
}
}
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: responseBody,
});
});
});
req.on('error', (e) => {
reject(e);
});
req.write(postData);
req.end();
});
}
}
const vhook = {
create_vhook_keypair,
create_vhook,
decode_vhook,
decode_vhook_without_validation,
extract_vhook_payload,
prepare_vhook_payload,
send_vhook,
};
module.exports = vhook;