278 lines
6.8 KiB
JavaScript
278 lines
6.8 KiB
JavaScript
'use strict';
|
|
|
|
const utils = require('./utils');
|
|
const {StripeError, StripeSignatureVerificationError} = require('./Error');
|
|
|
|
const Webhook = {
|
|
DEFAULT_TOLERANCE: 300, // 5 minutes
|
|
|
|
constructEvent(payload, header, secret, tolerance, cryptoProvider) {
|
|
this.signature.verifyHeader(
|
|
payload,
|
|
header,
|
|
secret,
|
|
tolerance || Webhook.DEFAULT_TOLERANCE,
|
|
cryptoProvider
|
|
);
|
|
|
|
const jsonPayload = JSON.parse(payload);
|
|
return jsonPayload;
|
|
},
|
|
|
|
async constructEventAsync(
|
|
payload,
|
|
header,
|
|
secret,
|
|
tolerance,
|
|
cryptoProvider
|
|
) {
|
|
await this.signature.verifyHeaderAsync(
|
|
payload,
|
|
header,
|
|
secret,
|
|
tolerance || Webhook.DEFAULT_TOLERANCE,
|
|
cryptoProvider
|
|
);
|
|
|
|
const jsonPayload = JSON.parse(payload);
|
|
return jsonPayload;
|
|
},
|
|
|
|
/**
|
|
* Generates a header to be used for webhook mocking
|
|
*
|
|
* @typedef {object} opts
|
|
* @property {number} timestamp - Timestamp of the header. Defaults to Date.now()
|
|
* @property {string} payload - JSON stringified payload object, containing the 'id' and 'object' parameters
|
|
* @property {string} secret - Stripe webhook secret 'whsec_...'
|
|
* @property {string} scheme - Version of API to hit. Defaults to 'v1'.
|
|
* @property {string} signature - Computed webhook signature
|
|
* @property {CryptoProvider} cryptoProvider - Crypto provider to use for computing the signature if none was provided. Defaults to NodeCryptoProvider.
|
|
*/
|
|
generateTestHeaderString: function(opts) {
|
|
if (!opts) {
|
|
throw new StripeError({
|
|
message: 'Options are required',
|
|
});
|
|
}
|
|
|
|
opts.timestamp =
|
|
Math.floor(opts.timestamp) || Math.floor(Date.now() / 1000);
|
|
opts.scheme = opts.scheme || signature.EXPECTED_SCHEME;
|
|
|
|
opts.cryptoProvider = opts.cryptoProvider || getNodeCryptoProvider();
|
|
|
|
opts.signature =
|
|
opts.signature ||
|
|
opts.cryptoProvider.computeHMACSignature(
|
|
opts.timestamp + '.' + opts.payload,
|
|
opts.secret
|
|
);
|
|
|
|
const generatedHeader = [
|
|
't=' + opts.timestamp,
|
|
opts.scheme + '=' + opts.signature,
|
|
].join(',');
|
|
|
|
return generatedHeader;
|
|
},
|
|
};
|
|
|
|
const signature = {
|
|
EXPECTED_SCHEME: 'v1',
|
|
|
|
verifyHeader(
|
|
encodedPayload,
|
|
encodedHeader,
|
|
secret,
|
|
tolerance,
|
|
cryptoProvider
|
|
) {
|
|
const {
|
|
decodedHeader: header,
|
|
decodedPayload: payload,
|
|
details,
|
|
} = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME);
|
|
|
|
cryptoProvider = cryptoProvider || getNodeCryptoProvider();
|
|
const expectedSignature = cryptoProvider.computeHMACSignature(
|
|
makeHMACContent(payload, details),
|
|
secret
|
|
);
|
|
|
|
validateComputedSignature(
|
|
payload,
|
|
header,
|
|
details,
|
|
expectedSignature,
|
|
tolerance
|
|
);
|
|
|
|
return true;
|
|
},
|
|
|
|
async verifyHeaderAsync(
|
|
encodedPayload,
|
|
encodedHeader,
|
|
secret,
|
|
tolerance,
|
|
cryptoProvider
|
|
) {
|
|
const {
|
|
decodedHeader: header,
|
|
decodedPayload: payload,
|
|
details,
|
|
} = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME);
|
|
|
|
cryptoProvider = cryptoProvider || getNodeCryptoProvider();
|
|
|
|
const expectedSignature = await cryptoProvider.computeHMACSignatureAsync(
|
|
makeHMACContent(payload, details),
|
|
secret
|
|
);
|
|
|
|
return validateComputedSignature(
|
|
payload,
|
|
header,
|
|
details,
|
|
expectedSignature,
|
|
tolerance
|
|
);
|
|
},
|
|
};
|
|
|
|
function makeHMACContent(payload, details) {
|
|
return `${details.timestamp}.${payload}`;
|
|
}
|
|
|
|
function parseEventDetails(encodedPayload, encodedHeader, expectedScheme) {
|
|
const decodedPayload = Buffer.isBuffer(encodedPayload)
|
|
? encodedPayload.toString('utf8')
|
|
: encodedPayload;
|
|
|
|
// Express's type for `Request#headers` is `string | []string`
|
|
// which is because the `set-cookie` header is an array,
|
|
// but no other headers are an array (docs: https://nodejs.org/api/http.html#http_message_headers)
|
|
// (Express's Request class is an extension of http.IncomingMessage, and doesn't appear to be relevantly modified: https://github.com/expressjs/express/blob/master/lib/request.js#L31)
|
|
if (Array.isArray(encodedHeader)) {
|
|
throw new Error(
|
|
'Unexpected: An array was passed as a header, which should not be possible for the stripe-signature header.'
|
|
);
|
|
}
|
|
|
|
const decodedHeader = Buffer.isBuffer(encodedHeader)
|
|
? encodedHeader.toString('utf8')
|
|
: encodedHeader;
|
|
|
|
const details = parseHeader(decodedHeader, expectedScheme);
|
|
|
|
if (!details || details.timestamp === -1) {
|
|
throw new StripeSignatureVerificationError({
|
|
message: 'Unable to extract timestamp and signatures from header',
|
|
detail: {
|
|
decodedHeader,
|
|
decodedPayload,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!details.signatures.length) {
|
|
throw new StripeSignatureVerificationError({
|
|
message: 'No signatures found with expected scheme',
|
|
detail: {
|
|
decodedHeader,
|
|
decodedPayload,
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
decodedPayload,
|
|
decodedHeader,
|
|
details,
|
|
};
|
|
}
|
|
|
|
function validateComputedSignature(
|
|
payload,
|
|
header,
|
|
details,
|
|
expectedSignature,
|
|
tolerance
|
|
) {
|
|
const signatureFound = !!details.signatures.filter(
|
|
utils.secureCompare.bind(utils, expectedSignature)
|
|
).length;
|
|
|
|
if (!signatureFound) {
|
|
throw new StripeSignatureVerificationError({
|
|
message:
|
|
'No signatures found matching the expected signature for payload.' +
|
|
' Are you passing the raw request body you received from Stripe?' +
|
|
' https://github.com/stripe/stripe-node#webhook-signing',
|
|
detail: {
|
|
header,
|
|
payload,
|
|
},
|
|
});
|
|
}
|
|
|
|
const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp;
|
|
|
|
if (tolerance > 0 && timestampAge > tolerance) {
|
|
throw new StripeSignatureVerificationError({
|
|
message: 'Timestamp outside the tolerance zone',
|
|
detail: {
|
|
header,
|
|
payload,
|
|
},
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function parseHeader(header, scheme) {
|
|
if (typeof header !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
return header.split(',').reduce(
|
|
(accum, item) => {
|
|
const kv = item.split('=');
|
|
|
|
if (kv[0] === 't') {
|
|
accum.timestamp = kv[1];
|
|
}
|
|
|
|
if (kv[0] === scheme) {
|
|
accum.signatures.push(kv[1]);
|
|
}
|
|
|
|
return accum;
|
|
},
|
|
{
|
|
timestamp: -1,
|
|
signatures: [],
|
|
}
|
|
);
|
|
}
|
|
|
|
let webhooksNodeCryptoProviderInstance = null;
|
|
|
|
/**
|
|
* Lazily instantiate a NodeCryptoProvider instance. This is a stateless object
|
|
* so a singleton can be used here.
|
|
*/
|
|
function getNodeCryptoProvider() {
|
|
if (!webhooksNodeCryptoProviderInstance) {
|
|
const NodeCryptoProvider = require('./crypto/NodeCryptoProvider');
|
|
webhooksNodeCryptoProviderInstance = new NodeCryptoProvider();
|
|
}
|
|
return webhooksNodeCryptoProviderInstance;
|
|
}
|
|
|
|
Webhook.signature = signature;
|
|
|
|
module.exports = Webhook;
|