Skip to content

Commit

Permalink
(ios) Make on-device (local) receipt validation an optional feature (…
Browse files Browse the repository at this point in the history
…disabled by default) which is enabled via a plugin variable
  • Loading branch information
Dave Alden authored and dpa99c committed Oct 3, 2022
1 parent 3bc786e commit 900c221
Show file tree
Hide file tree
Showing 16 changed files with 499 additions and 10 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ It lets you handle in-app purchases on many platforms with a single codebase.
cordova plugin add cordova-plugin-purchase
```

#### Plugin variables
When installing the plugin, you can set plugin variables at plugin installation time.
Note: in order to change the value of a plugin variable for an installed plugin, you must uninstall and reinstall the plugin with the new value.

Currently the following plugin variables are supported:

- `LOCAL_RECEIPT_VALIDATION` - whether to perform [local (on-device) receipt validation](doc/ios.md#on-device-validation)
- Defaults to `false` if not specified.
- To enable, set the value to `true` when installing the plugin:
- `cordova plugin add cordova-plugin-purchase --variable LOCAL_RECEIPT_VALIDATION=true`

### Install the plugin (PhoneGap)

Add the following to your `config.xml` file:
Expand Down
15 changes: 15 additions & 0 deletions doc/ios.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,18 @@ store.error(function(e){
// Refresh the store to start everything
store.refresh();
```

### Receipt validation

As outlined in the [API documentation](api.md#receipt-validation), validation of app store receipts should be used to prevent users faking in-app purchases in order to access paid-for features for free.

#### Server-side validation
- If you at all are concerned about security and possibility of exploitation of your paid app features, then you should use server-side (remote) validation as this is more secure and harder defeat than on-device validation.
- You can use an out-of-the-box solution such as [Fovea.Billing](https://billing.fovea.cc/) or implement your own server-side solution.

#### On-device validation
- In some circumstances where the in-app products are low-value or niche, server-side validation/parsing may seem like overkill and client-side validation/parsing of the app store receipt within the app on the device is sufficient.
- For this use case, this plugin implements on-device validation using the [RMStore](https://github.com/robotmedia/RMStore) library to provide the ability for the plugin to validate/parse app store receipts on the device.
- By default, this is functionality disabled but can be enabled at [plugin installation time using a plugin variable](../README.md#plugin-variables).
- Note: if the plugin is already installed, you'll need to uninstall and re-install it with the new plugin variable value.
- Note: the RMStore implementation uses the [OpenSSL crypto library](https://www.openssl.org/) which will be pulled into the app build and therefore **enabling on-device validation will add about 20Mb to the size of your app**.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@
],
"scripts": {
"test": "make test-js",
"coverage": "make test-js-coverage"
"coverage": "make test-js-coverage",
"postinstall": "node ./src/ios/local-receipt-validation/apply-module.js"
},
"author": "Jean-Christophe Hoelt <[email protected]>",
"license": "MIT",
"bugs": {
"url": "https://github.com/j3k0/cordova-plugin-purchase/issues"
},
"homepage": "https://github.com/j3k0/cordova-plugin-purchase",
"dependencies": {
"xml-js": "^1.6.11"
},
"devDependencies": {
"acorn": "^6.3.0",
"babel-eslint": "^10.0.3",
Expand Down
32 changes: 23 additions & 9 deletions plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,22 @@ SOFTWARE.
<source-file src="src/ios/SKProductDiscount+LocalizedPrice.m" />
<header-file src="src/ios/FileUtility.h" />
<source-file src="src/ios/FileUtility.m" />
<header-file src="src/ios/RMAppReceipt.h" />
<source-file src="src/ios/RMAppReceipt.m" />
<header-file src="src/ios/RMStoreAppReceiptVerifier.h" />
<source-file src="src/ios/RMStoreAppReceiptVerifier.m" />
<header-file src="src/ios/RMStore.h" />
<source-file src="src/ios/RMStore.m" />

<resource-file src="src/ios/AppleIncRootCertificate.cer" />

<framework src="StoreKit.framework" />

<preference name="LOCAL_RECEIPT_VALIDATION" default="false" />

<!--BEGIN_MODULE LOCAL_RECEIPT_VALIDATION--><!--
<header-file src="src/ios/local-receipt-validation/RMAppReceipt.h" />
<source-file src="src/ios/local-receipt-validation/RMAppReceipt.m" />
<header-file src="src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.h" />
<source-file src="src/ios/local-receipt-validation/RMStoreAppReceiptVerifier.m" />
<header-file src="src/ios/local-receipt-validation/RMStore.h" />
<source-file src="src/ios/local-receipt-validation/RMStore.m" />
<resource-file src="src/ios/local-receipt-validation/AppleIncRootCertificate.cer" />
<podspec>
<config>
<source url="https://cdn.cocoapods.org/"/>
Expand All @@ -84,9 +89,18 @@ SOFTWARE.
<pod name="OpenSSL-Universal" spec="1.1.1100"/>
</pods>
</podspec>
</platform>
--><!--END_MODULE LOCAL_RECEIPT_VALIDATION-->

<!--BEGIN_MODULE_STUB LOCAL_RECEIPT_VALIDATION-->
<header-file src="src/ios/local-receipt-validation/stub/RMAppReceipt.h" />
<source-file src="src/ios/local-receipt-validation/stub/RMAppReceipt.m" />
<header-file src="src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.h" />
<source-file src="src/ios/local-receipt-validation/stub/RMStoreAppReceiptVerifier.m" />
<!--END_MODULE_STUB LOCAL_RECEIPT_VALIDATION-->

</platform>

<!-- osx -->
<!-- osx -->
<platform name="osx">
<js-module src="www/store-ios.js" name="InAppPurchase">
<clobbers target="store" />
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
218 changes: 218 additions & 0 deletions src/ios/local-receipt-validation/apply-module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
#!/usr/bin/env node

/**********
* Globals
**********/

const PLUGIN_NAME = "Purchase plugin";
const PLUGIN_ID = "cordova-plugin-purchase";

const MODULE_NAME = "LOCAL_RECEIPT_VALIDATION";

const COMMENT_START = "<!--";
const COMMENT_END = "-->";

// Node dependencies
let path, cwd, fs;

// External dependencies
let parser;

// Global vars
let projectPath, modulesPath, pluginNodePath,
projectPackageJsonPath, projectPackageJsonData,
configXmlPath, configXmlData,
pluginXmlPath, pluginXmlText, pluginXmlData;


/*********************
* Internal functions
*********************/

const run = function (){
if(shouldModuleBeEnabled()){
enableModule();
}else{
disableModule();
}
writePluginXmlText();
};


const handleError = function (errorMsg, errorObj) {
errorMsg = PLUGIN_NAME + " - ERROR: " + errorMsg;
console.error(errorMsg);
console.dir(errorObj);
return errorMsg;
throw errorObj;
};

const shouldModuleBeEnabled = function(){
const pluginVariables = parsePluginVariables();
return resolveBoolean(pluginVariables[MODULE_NAME]);
};

const resolveBoolean = function(value){
if(typeof value === 'undefined' || value === null) return false;
if(value === true || value === false) return value;
return !isNaN(value) ? parseFloat(value) : /^\s*(true|false)\s*$/i.exec(value) ? RegExp.$1.toLowerCase() === "true" : value;
};

const enableModule = function(){
console.log(`Enabling ${MODULE_NAME} module in ${PLUGIN_ID}`);
const commentedStartRegExp = new RegExp(getModuleStart(MODULE_NAME)+COMMENT_START, "g");
const commentedEndRegExp = new RegExp(COMMENT_END+getModuleEnd(MODULE_NAME), "g");
if(pluginXmlText.match(commentedStartRegExp)){
pluginXmlText = pluginXmlText.replace(commentedStartRegExp, getModuleStart(MODULE_NAME));
pluginXmlText = pluginXmlText.replace(commentedEndRegExp, getModuleEnd(MODULE_NAME));
}

const commentedStubStart = getModuleStubStart(MODULE_NAME)+COMMENT_START;
const commentedStubEnd = COMMENT_END+getModuleStubEnd(MODULE_NAME);
if(!pluginXmlText.match(commentedStubStart)){
pluginXmlText = pluginXmlText.replace(new RegExp(getModuleStubStart(MODULE_NAME), "g"), commentedStubStart);
pluginXmlText = pluginXmlText.replace(new RegExp(getModuleStubEnd(MODULE_NAME), "g"), commentedStubEnd);
}
};

const disableModule = function(MODULE_NAME){
console.log(`Disabling ${MODULE_NAME} module in ${PLUGIN_ID}`);
const commentedStart = getModuleStart(MODULE_NAME)+COMMENT_START;
const commentedEnd = COMMENT_END+getModuleEnd(MODULE_NAME);
if(!pluginXmlText.match(commentedStart)){
pluginXmlText = pluginXmlText.replace(new RegExp(getModuleStart(MODULE_NAME), "g"), commentedStart);
pluginXmlText = pluginXmlText.replace(new RegExp(getModuleEnd(MODULE_NAME), "g"), commentedEnd);
}

const commentedStubStartRegExp = new RegExp(getModuleStubStart(MODULE_NAME)+COMMENT_START, "g");
const commentedStubEndRegExp = new RegExp(COMMENT_END+getModuleStubEnd(MODULE_NAME), "g");
if(pluginXmlText.match(commentedStubStartRegExp)){
pluginXmlText = pluginXmlText.replace(commentedStubStartRegExp, getModuleStubStart(MODULE_NAME));
pluginXmlText = pluginXmlText.replace(commentedStubEndRegExp, getModuleStubEnd(MODULE_NAME));
}
};

const getModuleStart = function(){
return "<!--BEGIN_MODULE "+MODULE_NAME+"-->";
};

const getModuleEnd = function(){
return "<!--END_MODULE "+MODULE_NAME+"-->";
};

const getModuleStubStart = function(){
return "<!--BEGIN_MODULE_STUB "+MODULE_NAME+"-->";
};

const getModuleStubEnd = function(){
return "<!--END_MODULE_STUB "+MODULE_NAME+"-->";
};

const parsePluginVariables = function(){

const pluginVariables = {};
// Parse plugin.xml
const plugin = parsePluginXml();
let prefs = [];
if(plugin.plugin.preference){
prefs = prefs.concat(plugin.plugin.preference);
}
if(typeof plugin.plugin.platform.length === 'undefined') plugin.plugin.platform = [plugin.plugin.platform];
plugin.plugin.platform.forEach(function(platform){
if(platform.preference){
prefs = prefs.concat(platform.preference);
}
});
prefs.forEach(function(pref){
if (pref._attributes){
pluginVariables[pref._attributes.name] = pref._attributes.default;
}
});

// Parse config.xml
const config = parseConfigXml();
(config.widget.plugin ? [].concat(config.widget.plugin) : []).forEach(function(plugin){
(plugin.variable ? [].concat(plugin.variable) : []).forEach(function(variable){
if((plugin._attributes.name === PLUGIN_ID || plugin._attributes.id === PLUGIN_ID) && variable._attributes.name && variable._attributes.value){
pluginVariables[variable._attributes.name] = variable._attributes.value;
}
});
});

// Parse package.json
const packageJSON = parsePackageJson();
if(packageJSON.cordova && packageJSON.cordova.plugins){
for(const pluginId in packageJSON.cordova.plugins){
if(pluginId === PLUGIN_ID){
for(const varName in packageJSON.cordova.plugins[pluginId]){
const varValue = packageJSON.cordova.plugins[pluginId][varName];
pluginVariables[varName] = varValue;
}
}
}
}

return pluginVariables;
};

const parsePackageJson = function(){
if(projectPackageJsonData) return projectPackageJsonData;
projectPackageJsonData = JSON.parse(fs.readFileSync(projectPackageJsonPath));
return projectPackageJsonData;
};

const parseConfigXml = function(){
if(configXmlData) return configXmlData;
data = parseXmlFileToJson(configXmlPath);
configXmlData = data.xml;
return configXmlData;
};

const parsePluginXml = function(){
if(pluginXmlData) return pluginXmlData;
const data = parseXmlFileToJson(pluginXmlPath);
pluginXmlText = data.text;
pluginXmlData = data.xml;
return pluginXmlData;
};

const parseXmlFileToJson = function(filepath, parseOpts){
parseOpts = parseOpts || {compact: true};
const text = fs.readFileSync(path.resolve(filepath), 'utf-8');
const xml = JSON.parse(parser.xml2json(text, parseOpts));
return {text, xml};
};

const writePluginXmlText = function(){
fs.writeFileSync(pluginXmlPath, pluginXmlText, 'utf-8');
};

/**********
* Main
**********/
const main = function() {
try{
fs = require('fs');
path = require('path');

cwd = path.resolve();
pluginNodePath = cwd;

modulesPath = path.resolve(pluginNodePath, "..");
projectPath = path.resolve(modulesPath, "..");

parser = require(path.resolve(modulesPath, "xml-js"));
}catch(e){
handleError("Failed to load dependencies for "+PLUGIN_ID+"': " + e.message, e);
}

try{
projectPackageJsonPath = path.join(projectPath, 'package.json');
configXmlPath = path.join(projectPath, 'config.xml');
pluginXmlPath = path.join(pluginNodePath, "plugin.xml");
run();
}catch(e){
handleError(e.message, e);
}
};
main();
Loading

0 comments on commit 900c221

Please sign in to comment.