in-app-purchase-iec-develop
Version:
In-App-Purchase validation and subscription management for iOS, Android, Amazon, and Windows
782 lines (699 loc) • 28 kB
JavaScript
// google.js
var constants = require('../constants');
var fs = require('fs');
var crypto = require('crypto');
var async = require('./async');
var request = require('request');
var responseData = require('./responseData');
var verbose = require('./verbose');
var GoogleAPIModule = require('./googleAPI');
function GoogleModule() {
this.testMode = false;
this.useGoogleApi = false;
this.sandboxPkey = 'iap-sandbox';
this.livePkey = 'iap-live';
this.config = null;
this.keyPathMap = {};
this.publicKeyMap = {};
this.googleTokenMap = {};
this.checkSubscriptionState = false;
this.KEYS = {
ACCESS_TOKEN: 'access_token',
GRANT_TYPE: 'grant_type',
CLIENT_ID: 'client_id',
CLIENT_SECRET: 'client_secret',
REFRESH_TOKEN: 'refresh_token'
};
this.ENV_PUBLICKEY = {
SANDBOX: 'GOOGLE_IAB_PUBLICKEY_SANDBOX',
LIVE: 'GOOGLE_IAB_PUBLICKEY_LIVE'
};
this.NAME = '<Google>';
this.CANCELLATION_REASON = {
USER_CANCELLED: 0,
SYSTEM_CANCELLED: 1,
REPLACED: 2,
DEVELOPER_CANCELLED: 3
};
this.googleApi = new GoogleAPIModule();
}
GoogleModule.prototype.isValidConfigKey = function (key) {
return key.match(/^google/);
};
GoogleModule.prototype.reset = function () {
this.config = null;
this.keyPathMap = {};
this.publicKeyMap = {};
this.googleTokenMap = {};
this.checkSubscriptionState = false;
};
GoogleModule.prototype.readConfig = function (configIn) {
if (!configIn) {
return;
}
if (configIn.requestDefaults) {
request = request.defaults(configIn.requestDefaults);
}
verbose.setup(configIn);
this.testMode = configIn.test || false;
verbose.log(this.NAME, 'test mode?', this.testMode);
this.config = {};
var configValueSet = false;
Object.keys(configIn).forEach((key) => {
if (this.isValidConfigKey(key)) {
this.config[key] = configIn[key];
configValueSet = true;
}
});
if (configIn && configIn.publicKeyStrSandbox) {
this.config.googlePublicKeyStrSandbox = configIn.publicKeyStrSandbox;
}
if (configIn && configIn.publicKeyStrLive) {
this.config.googlePublicKeyStrLive = configIn.publicKeyStrLive;
}
if (!configValueSet) {
this.config = null;
return;
}
this.keyPathMap.sandbox = this.config.googlePublicKeyPath + this.sandboxPkey;
this.keyPathMap.live = this.config.googlePublicKeyPath + this.livePkey;
if (this.config.googleAccToken) {
this.googleTokenMap.accessToken = this.config.googleAccToken;
}
if (this.config.googleRefToken && this.config.googleClientID && this.config.googleClientSecret) {
this.googleTokenMap.refreshToken = this.config.googleRefToken;
this.googleTokenMap.clientID = this.config.googleClientID;
this.googleTokenMap.clientSecret = this.config.googleClientSecret;
this.checkSubscriptionState = true;
}
if (this.config.googleServiceAccount) {
this.useGoogleApi = true;
this.googleApi.config(this.config.googleServiceAccount);
}
};
GoogleModule.prototype.setup = function (cb) {
if (this.config && (this.config.googlePublicKeyStrSandbox || this.config.googlePublicKeyStrLive || this.config.googleAPIKeyData)) {
if (this.config && this.config.googlePublicKeyStrSandbox) {
this.publicKeyMap.sandbox = this.config.googlePublicKeyStrSandbox;
}
if (this.config && this.config.googlePublicKeyStrLive) {
this.publicKeyMap.live = this.config.googlePublicKeyStrLive;
}
return cb();
}
if (!this.config || !this.config.googlePublicKeyPath) {
if (process.env[this.ENV_PUBLICKEY.SANDBOX]) {
this.publicKeyMap.sandbox = process.env[this.ENV_PUBLICKEY.SANDBOX].replace(/s+$/, '');
}
if (process.env[this.ENV_PUBLICKEY.LIVE]) {
this.publicKeyMap.live = process.env[this.ENV_PUBLICKEY.LIVE].replace(/s+$/, '');
}
return cb();
}
var keys = Object.keys(this.keyPathMap);
async.eachSeries(keys, (key, next) => {
var pkeyPath = this.keyPathMap[key];
fs.readFile(pkeyPath, (error, fileData) => {
if (error) {
return next();
}
this.publicKeyMap[key] = fileData.toString().replace(/\s+$/, '');
next();
});
}, cb);
};
GoogleModule.prototype.validatePurchase = function (dPubkey, receipt, cb) {
verbose.log(this.NAME, 'Validate this:', receipt);
if (this.useGoogleApi) {
return this.googleApi.validatePurchase(dPubkey, receipt, cb);
} else if (dPubkey && dPubkey.clientEmail && dPubkey.privateKey) {
return this.googleApi.validatePurchase(dPubkey, receipt, cb);
}
if (typeof receipt !== 'object') {
verbose.log(this.NAME, 'Failed: malformed receipt');
return cb(new Error('malformed receipt: ' + receipt), {
status: constants.VALIDATION.FAILURE,
message: 'Malformed receipt'
});
}
if (receipt.receipt && !receipt.data) {
receipt.data = receipt.receipt;
}
if (!receipt.data || !receipt.signature) {
verbose.log(this.NAME, 'Failed: missing receipt content');
return cb(new Error('missing receipt data:\n' + JSON.stringify(receipt)), {
status: constants.VALIDATION.FAILURE,
message: 'Malformed receipt'
});
}
if (typeof receipt.data === 'object') {
receipt.data = JSON.stringify(receipt.data).replace(/\//g, '\\/');
verbose.log(this.NAME, 'Auto stringified receipt data:', receipt.data);
}
var pubkey = this.publicKeyMap.live;
var tokenMap = {
clientId: this.googleTokenMap.clientID,
clientSecret: this.googleTokenMap.clientSecret,
refreshToken: this.googleTokenMap.refreshToken
};
if (typeof dPubkey === 'string') {
verbose.log(this.NAME, 'Using dynamically fed public key:', dPubkey);
pubkey = dPubkey;
}
if (dPubkey && typeof dPubkey === 'object') {
if (dPubkey.clientId && dPubkey.clientSecret && dPubkey.refreshToken) {
verbose.log(this.NAME, 'Using dynamically fed google client ID, client secret, and refresh token:', dPubkey);
tokenMap.clientId = dPubkey.clientId;
tokenMap.clientSecret = dPubkey.clientSecret;
tokenMap.refreshToken = dPubkey.refreshToken;
}
}
if (receipt.signature.indexOf(' ') > -1) {
receipt.signature = receipt.signature.replace(/\ /g, '+');
}
var validateMethod;
if (tokenMap.clientId && tokenMap.clientSecret && tokenMap.refreshToken) {
verbose.log(this.NAME, 'Validation w/ Google Play API');
validateMethod = (_cb) => {
this.validateProduct(receipt, tokenMap, _cb);
};
} else {
verbose.log(this.NAME, 'Validation w/ public key');
validateMethod = (_cb) => {
this.validatePublicKey(receipt, this.getPublicKey(pubkey), (error, data) => {
if (error) {
if (this.isSubscription(receipt)) {
return this.validateSubscription(receipt, tokenMap, cb);
}
}
_cb(error, data);
});
};
}
verbose.log(this.NAME, 'Try validate against live public key:', pubkey);
if (this.testMode) {
validateMethod((error, data) => {
if (error) {
verbose.log(this.NAME, 'Failed against sandbox public key:', error);
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
verbose.log(this.NAME, 'Validation against sandbox public key successful:', data);
this.checkSubscriptionStatus(data, cb);
});
return;
}
validateMethod((error, data) => {
if (error) {
if (!this.publicKeyMap.sandbox) {
verbose.log(this.NAME, 'Failed to validate against:', pubkey, error);
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
pubkey = this.publicKeyMap.sandbox;
verbose.log(this.NAME, 'Failed against live public key:', error);
verbose.log(this.NAME, 'Try validate against sandbox public key:', pubkey);
receipt.data = JSON.stringify(receipt.data).replace(/\//g, '\\/');
validateMethod((error2, data) => {
if (error2) {
verbose.log(this.NAME, 'Failed against sandbox public key:', error2);
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
verbose.log(this.NAME, 'Validation against sandbox public key successful:', data);
this.checkSubscriptionStatus(data, cb);
});
return;
}
verbose.log(this.NAME, 'Validation against live public key successful:', data);
this.checkSubscriptionStatus(data, cb);
});
};
GoogleModule.prototype.isSubscription = function (receipt) {
if (typeof receipt === 'string') {
try {
receipt = JSON.parse(receipt);
return receipt.data && receipt.data.autoRenewing !== undefined;
} catch (error) {
return false;
}
}
if (typeof receipt.data === 'string') {
try {
receipt.data = JSON.parse(receipt.data);
return receipt.data && receipt.data.autoRenewing !== undefined;
} catch (error) {
return false;
}
}
return receipt.data && receipt.data.autoRenewing !== undefined;
};
GoogleModule.prototype.validateProduct = function (receipt, tokenMap, cb) {
if (this.isSubscription(receipt)) {
return this.validateSubscription(receipt, tokenMap, cb);
}
if (typeof receipt === 'string') {
try {
receipt = JSON.parse(receipt);
} catch (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
}
if (typeof receipt.data === 'string') {
try {
receipt.data = JSON.parse(receipt.data);
} catch (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
}
verbose.log(this.NAME, 'Validate purchase as product w/:', tokenMap);
if (!tokenMap.clientId || !tokenMap.clientSecret || !tokenMap.refreshToken) {
return cb(new Error('missing google play api info'), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: 'client_id, client_secret, access_token and refres_token should be provided'
}));
}
this.auth(tokenMap, (error, body) => {
if (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
verbose.log(this.NAME, 'Google API authenticated', body.body);
var accessToken = body.access_token || body.body.access_token;
verbose.log(this.NAME, 'Google API access token:', accessToken);
if (!accessToken) {
return cb(new Error(JSON.stringify(body)), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: 'failed to authenticate api call'
}));
}
var url = 'https://www.googleapis.com/androidpublisher/v3/applications/' +
encodeURIComponent(receipt.data.packageName) +
'/purchases/products/' +
encodeURIComponent(receipt.data.productId) +
'/tokens/' + encodeURIComponent(receipt.data.purchaseToken);
request.get({
url: url,
headers: { "Authorization": 'Bearer '+ accessToken},
json: true
}, (error, res, body) => {
if (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
if (res.statusCode === 410) {
verbose.log(this.NAME, 'Receipt is no longer valid');
return cb(new Error('ReceiptNoLongerValid'), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: body
}));
}
if (res.statusCode >= 399) {
verbose.log(this.NAME, 'Product validation failed:', body, res.statusCode);
return cb(new Error('Status:' + res.statusCode), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: body
}));
}
if (body.error && body.errors.length > 0) {
verbose.log(this.NAME, 'Product validation failed with error:', body.error);
return cb(new Error(body.error.message), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: body.error.message
}));
}
if (body.purchaseState !== 0) {
verbose.log(this.NAME, 'Purchase has been canceled:', body);
return cb(new Error('PurchaseCanceled'), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: 'PurchaseCanceled'
}));
}
verbose.log(this.NAME, 'Product purchase validated:', body);
var resp = receipt.data;
resp.status = constants.VALIDATION.SUCCESS;
for (var key in body) {
resp[key] = body[key];
}
resp.service = constants.SERVICES.GOOGLE;
cb(null, resp);
});
});
};
GoogleModule.prototype.validateSubscription = function (receipt, tokenMap, cb) {
if (typeof receipt === 'string') {
try {
receipt = JSON.parse(receipt);
} catch (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
}
if (typeof receipt.data === 'string') {
try {
receipt.data = JSON.parse(receipt.data);
} catch (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
}
verbose.log(this.NAME, 'Validate purchase as subscription w/:', tokenMap);
if (!tokenMap.clientId || !tokenMap.clientSecret || !tokenMap.refreshToken) {
return cb(new Error('missing google play api info'), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: 'client_id, client_secret, access_token and refres_token should be provided'
}));
}
this.auth(tokenMap, (error, res) => {
if (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
verbose.log(this.NAME, 'Google API authenticated', res.body);
var accessToken = res.access_token || res.body.access_token;
verbose.log(this.NAME, 'Google API access token:', accessToken);
if (!accessToken) {
return cb(new Error(JSON.stringify(res.body || res)), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: 'failed to authenticate api call'
}));
}
var url = 'https://www.googleapis.com/androidpublisher/v3/applications/' +
encodeURIComponent(receipt.data.packageName) +
'/purchases/subscriptions/' +
encodeURIComponent(receipt.data.productId) +
'/tokens/' + encodeURIComponent(receipt.data.purchaseToken);
request.get(
{
url: url,
headers: { Authorization: 'Bearer ' + accessToken },
json: true,
},
(error, res, body) => {
if (error) {
return cb(error, this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
if (res.statusCode >= 399) {
verbose.log(this.NAME, 'Product validation failed:', body, res.statusCode);
return cb(new Error('Status:' + res.statusCode), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: body
}));
}
if (body.error && body.errors.length > 0) {
verbose.log(this.NAME, 'Product validation failed with error:', body.error);
return cb(new Error(body.error.message), this.createErrorData(receipt, {
status: constants.VALIDATION.FAILURE,
message: body.error.message
}));
}
verbose.log(this.NAME, 'Subscription purchase validated:', body);
var resp = receipt.data;
resp.status = constants.VALIDATION.SUCCESS;
for (var key in body) {
resp[key] = body[key];
}
resp.service = constants.SERVICES.GOOGLE;
cb(null, resp);
}
);
});
};
GoogleModule.prototype.getPurchaseData = function (purchase, options) {
if (!purchase) {
return null;
}
if (options && options.ignoreExpired) {
var now = Date.now();
if (purchase.expiryTimeMillis <= now) {
return [];
} else if (purchase.expirationTime <= now) {
return [];
} else if (
purchase.cancelReason &&
purchase.cancelReason !== this.CANCELLATION_REASON.USER_CANCELLED
) {
return [];
}
}
var data = [];
var purchaseInfo = responseData.parse(purchase);
purchaseInfo.transactionId = purchase.orderId;
purchaseInfo.purchaseDate = purchase.purchaseTime;
purchaseInfo.quantity = 1;
if (this.checkSubscriptionState) {
purchaseInfo.expirationDate = purchase.expirationTime;
}
if (purchase.expiryTimeMillis) {
purchaseInfo.expirationDate = purchase.expiryTimeMillis;
}
if (
purchase.cancelReason &&
purchase.cancelReason !== this.CANCELLATION_REASON.USER_CANCELLED
) {
purchaseInfo.cancellationDate = purchase.expiryTimeMillis;
}
data.push(purchaseInfo);
return data;
};
GoogleModule.prototype.checkSubscriptionStatus = function (data, cb) {
data.service = constants.SERVICES.GOOGLE;
if (!this.checkSubscriptionState) {
return cb(null, data);
}
var packageName = data.packageName;
var subscriptionID = data.productId;
var purchaseToken = data.purchaseToken;
var urlPurchaseTypeSegment = data.autoRenewing === undefined ? 'products' : 'subscriptions';
var url = 'https://www.googleapis.com/androidpublisher/v3/applications/' + packageName +
'/purchases/' + urlPurchaseTypeSegment + '/' + subscriptionID + '/tokens/' + purchaseToken;
var state;
var mustRecheck = false;
var getSubInfo = (next) => {
verbose.log(this.NAME, 'Get subscription info from', url);
this.getSubscriptionInfo(url, (error, response, body) => {
if (error || 'error' in body) {
verbose.log(this.NAME, 'Failed to get subscription info from', url, error, body);
state = constants.VALIDATION.FAILURE;
next();
return;
}
this._copyPurchaseDetails(data, body);
state = constants.VALIDATION.SUCCESS;
verbose.log(this.NAME, 'Successfully retrieved subscription info from', url, data);
next();
});
};
var validate = (next) => {
switch (state) {
case constants.VALIDATION.SUCCESS:
verbose.log(this.NAME, 'Validated successfully');
next(null, constants.VALIDATION.FAILURE);
break;
case constants.VALIDATION.FAILURE:
verbose.log(this.NAME, 'Refresh Google token');
this.refreshGoogleTokens((error, res, body) => {
if (error) {
verbose.log(this.NAME, 'Failed to refresh Google token:', error);
return cb(error, this.createErrorData({ data: data }, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
var parsedBody = JSON.parse(body);
if ('error' in parsedBody) {
verbose.log(this.NAME, 'Failed to refresh Google token:', parsedBody);
return cb(new Error(parsedBody.error), this.createErrorData({ data: data }, {
status: constants.VALIDATION.FAILURE,
message: parsedBody.error
}));
}
this.googleTokenMap.accessToken = parsedBody[this.KEYS.ACCESS_TOKEN];
state = constants.VALIDATION.SUCCESS;
mustRecheck = true;
verbose.log(this.NAME, 'Successfully refreshed Google token:', this.googleTokenMap.accessToken);
next();
});
break;
}
};
var recheck = (next) => {
if (state === constants.VALIDATION.SUCCESS) {
if (!mustRecheck) {
return next();
}
verbose.log(this.NAME, 'Re-check subscription info:', url);
this.getSubscriptionInfo(url, (error, response, body) => {
if (error || 'error' in body) {
verbose.log(this.NAME, 'Re-check failed:', url, error, body);
state = constants.VALIDATION.FAILURE;
next(error ? error : new Error(body.error));
return;
}
this._copyPurchaseDetails(data, body);
state = constants.VALIDATION.SUCCESS;
verbose.log(this.NAME, 'Re-check successfully retrieved subscription info:', url, data);
next();
});
return;
}
state = constants.VALIDATION.FAILURE;
next();
};
var done = (error) => {
if (error) {
return cb(error, this.createErrorData({ data: data }, {
status: constants.VALIDATION.FAILURE,
message: error.message
}));
}
cb(null, data);
};
var tasks = [
getSubInfo,
validate,
recheck
];
async.series(tasks, done);
};
GoogleModule.prototype._copyPurchaseDetails = function (data, body) {
data.purchaseState = body.purchaseState;
data.autoRenewing = body.autoRenewing;
data.expirationTime = body.expiryTimeMillis;
data.paymentState = body.paymentState;
data.priceCurrencyCode = body.priceCurrencyCode;
data.priceAmountMicros = body.priceAmountMicros;
data.cancelReason = body.cancelReason;
data.countryCode = body.countryCode;
};
GoogleModule.prototype.getPublicKey = function (publicKey) {
if (!publicKey) {
return null;
}
var key = this.chunkSplit(publicKey, 64, '\n');
var pkey = '-----BEGIN PUBLIC KEY-----\n' + key + '-----END PUBLIC KEY-----\n';
return pkey;
};
GoogleModule.prototype.validatePublicKey = function (receipt, pkey, cb) {
if (!receipt || !receipt.data) {
return cb(new Error('missing receipt data'));
}
if (!pkey) {
return cb(new Error('missing public key'));
}
if (typeof receipt.data !== 'string') {
return cb(new Error('receipt.data must be a string'));
}
var validater = crypto.createVerify('SHA1');
var valid;
validater.update(receipt.data);
try {
valid = validater.verify(pkey, receipt.signature, 'base64');
} catch (error) {
return cb(error);
}
if (valid) {
var data = JSON.parse(receipt.data);
data.status = constants.VALIDATION.SUCCESS;
return cb(null, data);
}
cb(new Error('failed to validate purchase'));
};
GoogleModule.prototype.chunkSplit = function (str, len, end) {
len = parseInt(len, 10) || 76;
if (len < 1) {
return false;
}
end = end || '\r\n';
return str.match(new RegExp('.{0,' + len + '}', 'g')).join(end);
};
GoogleModule.prototype.getSubscriptionInfo = function (url, cb) {
var options = {
method: 'GET',
url: url,
headers: {
'Authorization': 'Bearer ' + this.googleTokenMap.accessToken
},
json: true
};
request(options, cb);
};
GoogleModule.prototype.refreshToken = function (cb) {
if (!this.checkSubscriptionState) {
return cb(new Error('missing google play api info'), {
status: constants.VALIDATION.FAILURE,
message: 'client_id, client_secret, access_token and refres_token should be provided'
});
}
this.refreshGoogleTokens((error, res, body) => {
if (error) {
return cb(error, { status: constants.VALIDATION.FAILURE, message: error.message });
}
var parsedBody = JSON.parse(body);
if ('error' in parsedBody) {
return cb(new Error(parsedBody.error), {
status: constants.VALIDATION.FAILURE,
message: parsedBody.error
});
}
this.googleTokenMap.accessToken = parsedBody[this.KEYS.ACCESS_TOKEN];
cb(null, parsedBody);
});
};
GoogleModule.prototype.refreshGoogleTokens = function (cb) {
var body = {};
body[this.KEYS.GRANT_TYPE] = this.KEYS.REFRESH_TOKEN;
body[this.KEYS.CLIENT_ID] = this.googleTokenMap.clientID;
body[this.KEYS.CLIENT_SECRET] = this.googleTokenMap.clientSecret;
body[this.KEYS.REFRESH_TOKEN] = this.googleTokenMap.refreshToken;
var options = {
method: 'POST',
url: 'https://accounts.google.com/o/oauth2/token',
form: body
};
request(options, cb);
};
GoogleModule.prototype.auth = function (tokenMap, cb) {
var body = {};
body[this.KEYS.GRANT_TYPE] = this.KEYS.REFRESH_TOKEN;
body[this.KEYS.CLIENT_ID] = tokenMap.clientId;
body[this.KEYS.CLIENT_SECRET] = tokenMap.clientSecret;
body[this.KEYS.REFRESH_TOKEN] = tokenMap.refreshToken;
var options = {
method: 'POST',
url: 'https://accounts.google.com/o/oauth2/token',
form: body,
json: true
};
request(options, cb);
};
GoogleModule.prototype.createErrorData = function (receipt, obj) {
obj.data = receipt.data || null;
return obj;
};
module.exports = GoogleModule;