UNPKG

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
// 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;