UNPKG

vhook-js

Version:

Verifyable Webhooks

476 lines (406 loc) 16 kB
// 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;