UNPKG

mastercard-oauth1-signer

Version:

Zero dependency library for generating a Mastercard API compliant OAuth signature.

311 lines (263 loc) 10.3 kB
const crypto = require("crypto"); const url = require("url"); const EMPTY_STRING = ""; const SHA_BITS = "256"; const SignatureMethod = { RSA_SHA256: "RSA-SHA256", RSA_PSS_SHA256: "RSA-PSS-SHA256" }; const DEFAULT_SIGNATURE_METHOD = SignatureMethod.RSA_SHA256; class OAuth { /** * Creates a Mastercard API compliant OAuth Authorization header * * @param {String} uri Target URI for this request * @param {String} method HTTP method of the request * @param {Any} payload Payload (nullable) * @param {String} consumerKey Consumer key set up in a Mastercard Developer Portal project * @param {String} signingKey The private key that will be used for signing the request that corresponds to the consumerKey * @return {String} Valid OAuth1.0a signature with a body hash when payload is present */ static getAuthorizationHeader(uri, method, payload, consumerKey, signingKey, signatureMethod = DEFAULT_SIGNATURE_METHOD) { const queryParams = this.extractQueryParams(uri); const oauthParams = this.getOAuthParams(consumerKey, payload, signatureMethod); // Combine query and oauth_ parameters into lexicographically sorted string const paramString = this.toOAuthParamString(queryParams, oauthParams); // Normalized URI without query params and fragment const baseUri = this.getBaseUriString(uri); // Signature base string const sbs = this.getSignatureBaseString(method, baseUri, paramString); // Signature const signature = this.signSignatureBaseString(sbs, signingKey, signatureMethod); const encodedSignature = encodeURIComponent(signature); oauthParams.set("oauth_signature", encodedSignature); // Return return this.getAuthorizationString(oauthParams); } } module.exports = OAuth; OAuth.SignatureMethod = SignatureMethod; /** * Parse query parameters out of the URL. * https://tools.ietf.org/html/rfc5849#section-3.4.1.3 * * @param {String} uri URL containing all query parameters that need to be signed * @return {Map<String, Set<String>} Sorted map of query parameter key/value pairs. Values for parameters with the same name are added into a list. */ OAuth.extractQueryParams = function extractQueryParams(uri) { uri = url.parse(uri); const queryParams = uri.query; if (!queryParams) { return new Map(); } const queryPairs = new Map(); const pairs = queryParams .split("&") .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); for(let pair of pairs) { const idx = pair.indexOf("="); let key = idx > 0 ? pair.substring(0, idx) : pair; if(!queryPairs.has(key)) { queryPairs.set(key, new Set()); } let value = idx > 0 && pair.length > idx + 1 ? pair.substring(idx + 1) : EMPTY_STRING; queryPairs.get(key).add(value); } return queryPairs; }; /** * Constructs a valid Authorization header as per * https://tools.ietf.org/html/rfc5849#section-3.5.1 * * @param {Map<String, String>} oauthParams Map of OAuth parameters to be included in the Authorization header * @return {String} Correctly formatted header */ OAuth.getAuthorizationString = function getAuthorizationString(oauthParams) { let header = "OAuth "; for (const entry of oauthParams) { const entryKey = entry[0]; const entryVal = entry[1]; header = `${header}${entryKey}="${entryVal}",`; } // Remove trailing , header = header.slice(0, header.length - 1); return header; }; /** * Normalizes the URL as per * https://tools.ietf.org/html/rfc5849#section-3.4.1.2 * * @param {String} uri URL that will be called as part of this request * @return {String} Normalized URL */ OAuth.getBaseUriString = function getBaseUriString(uri) { const uriAsUrl = url.parse(uri); // Lowercase scheme and authority const protocol = uriAsUrl.protocol.toLowerCase(); const hostname = uriAsUrl.hostname.toLowerCase(); const pathname = uriAsUrl.pathname; // Remove query and fragment let baseUri=`${protocol}//${hostname}`; const hasNonStandardPort = ( uriAsUrl.port !== undefined && uriAsUrl.port !== null && uriAsUrl.port!==443 && uriAsUrl.port!==80 ); if(hasNonStandardPort) { baseUri = `${baseUri}:${uriAsUrl.port}`; } baseUri = `${baseUri}${pathname}`; return baseUri; }; /** * Generates a hash based on request payload as per * https://tools.ietf.org/id/draft-eaton-oauth-bodyhash-00.html * * @param {Any} payload Request payload * @return {String} Base64 encoded cryptographic hash of the given payload */ OAuth.getBodyHash = function getBodyHash(payload) { const bodyHash = crypto.createHash(`sha${SHA_BITS}`); bodyHash.update(payload, "utf8"); return bodyHash.digest("base64"); }; /** * Generates a random string for replay protection as per * https://tools.ietf.org/html/rfc5849#section-3.3 * * @return {String} UUID with dashes removed */ OAuth.getNonce = function getNonce() { const NONCE_LENGTH = 8; const VALID_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const bytes = crypto.randomBytes(NONCE_LENGTH); const value = new Array(NONCE_LENGTH).fill(0); return value.reduce((acc, val, i) => { acc.push(VALID_CHARS[bytes[i] % VALID_CHARS.length]); return acc; }, []).join(""); }; /** * @param {String} consumerKey Consumer key set up in a Mastercard Developer Portal project * @param {Any} payload Payload (nullable) * @return {Map} */ OAuth.getOAuthParams = function getOAuthParams(consumerKey, payload, signatureMethod = DEFAULT_SIGNATURE_METHOD) { validateSignatureMethod(signatureMethod); const oauthParams = new Map(); if (!payload) payload = EMPTY_STRING; oauthParams.set("oauth_body_hash", OAuth.getBodyHash(payload)); oauthParams.set("oauth_consumer_key", consumerKey); oauthParams.set("oauth_nonce", OAuth.getNonce()); oauthParams.set("oauth_signature_method", signatureMethod === SignatureMethod.RSA_PSS_SHA256 ? 'RSA-PSS' : 'RSA-SHA256' ); oauthParams.set("oauth_timestamp", OAuth.getTimestamp()); oauthParams.set("oauth_version", "1.0"); return oauthParams; }; /** * Generate a valid signature base string as per * https://tools.ietf.org/html/rfc5849#section-3.4.1 * * @param {String} httpMethod HTTP method of the request * @param {String} baseUri Base URI that conforms with https://tools.ietf.org/html/rfc5849#section-3.4.1.2 * @param {String} paramString OAuth parameter string that conforms with https://tools.ietf.org/html/rfc5849#section-3.4.1.3 * @return {String} A correctly constructed and escaped signature base string */ OAuth.getSignatureBaseString = function getSignatureBaseString(httpMethod, baseUri, paramString) { //encodeURIComponent doesnt encode * so doing it manually let encodeParamsString = encodeURIComponent(paramString); encodeParamsString = encodeParamsString.replace('*', '%2A'); const sbs = // Uppercase HTTP method `${httpMethod.toUpperCase()}&` + // Base URI `${encodeURIComponent(baseUri)}&` + // OAuth parameter string encodeParamsString; return sbs.replace(/!/, "%21"); }; /** * Returns UNIX Timestamp as required per * https://tools.ietf.org/html/rfc5849#section-3.3 * * @return {String} UNIX timestamp (UTC) */ OAuth.getTimestamp = function getTimestamp() { return Math.floor(Date.now() / 1000); }; /** * Signs the signature base string using an RSA private key. The methodology is described at * https://tools.ietf.org/html/rfc5849#section-3.4.3 but Mastercard uses the stronger SHA-256 algorithm * as a replacement for the described SHA1 which is no longer considered secure. * * @param {String} sbs Signature base string formatted as per https://tools.ietf.org/html/rfc5849#section-3.4.1 * @param {String} signingKey Private key of the RSA key pair that was established with the service provider * @return {String} RSA signature matching the contents of signature base string */ OAuth.signSignatureBaseString = function signSignatureBaseString(sbs, signingKey, signatureMethod = DEFAULT_SIGNATURE_METHOD) { validateSignatureMethod(signatureMethod); let signer = crypto.createSign("RSA-SHA256"); signer = signer.update(Buffer.from(sbs)); try { if(signatureMethod === SignatureMethod.RSA_PSS_SHA256) { return signer.sign({ key: signingKey, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST }, "base64"); } else { return signer.sign(signingKey, "base64"); } } catch (e) { throw new Error("Unable to sign the signature base string."); } }; /** * Lexicographically sort all parameters and concatenate them into a string as per * https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 * * @param {Map<String, Set<String>>} queryParamsMap Map of all oauth parameters that need to be signed * @param {Map<String, String>} oauthParamsMap Map of OAuth parameters to be included in Authorization header * @return {String} Correctly encoded and sorted OAuth parameter string */ OAuth.toOAuthParamString = function toOAuthParamString(queryParamsMap, oauthParamsMap) { const consolidatedParams = new Map(queryParamsMap); // Add OAuth params to consolidated params map for (const entry of oauthParamsMap) { const entryKey = entry[0]; const entryValue = entry[1]; if (consolidatedParams.has(entryKey)) { consolidatedParams.get(entryKey).add(entryValue); } else { consolidatedParams.set(entryKey, new Set().add(entryValue)); } } let consolidatedParamsAsc = new Map([...consolidatedParams.entries()].sort()); let allParams = ""; // Add all parameters to the parameter string for signing for (const entry of consolidatedParamsAsc) { const entryKey = entry[0]; let entryValues = entry[1]; // Keys with same name are sorted by their values if (entryValues.size > 1) { entryValues = new Set(Array.from(entryValues).sort()); } for (const entryValue of entryValues) { allParams = `${allParams}${entryKey}=${entryValue}&`; } } // Remove trailing ampersand const stringLength = allParams.length - 1; if (allParams.endsWith("&")) { allParams = allParams.slice(0, stringLength); } return allParams; }; function validateSignatureMethod(method) { if (!method || typeof method !== "string" || !Object.values(SignatureMethod).includes(method)) { throw new Error("Invalid/Unsupported signature method. Only these values are supported: " + Object.values(SignatureMethod)); } }