j3k0 / cordova-plugin-purchase

In-App Purchase for Cordova on iOS, Android and Windows
https://purchase.cordova.fovea.cc
1.3k stars 537 forks source link

response from iOS in-app purchase doesnt contain enough receipt data needed for receipt validation #426

Closed caleb87 closed 8 years ago

caleb87 commented 8 years ago

The product.transaction only returns the following for a successful IAP: {"type":"ios-appstore","id":"1000000200400771"}

Here's the entire object returned for when.approved():

{  
   "id":"subscription_1",
   "alias":"Subscription 20",
   "type":"paid subscription",
   "state":"approved",
   "title":"Subscription",
   "description":"Subscription",
   "price":"$19.99",
   "currency":null,
   "loaded":true,
   "canPurchase":false,
   "owned":false,
   "downloading":false,
   "downloaded":false,
   "transaction":{  
      "type":"ios-appstore",
      "id":"1000000200491361"
   },
   "valid":true,
   "transactions":[  
      "1000000200450592",
      "1000000200450626",
      "1000000200450699",
      "1000000200450768",
      "1000000200450968",
      "1000000200451015",
      "1000000200451978",
      "1000000200452019",
      "1000000200452040",
      "1000000200452082",
      "1000000200452130",
      "1000000200452155",
      "1000000200471605",
      "1000000200471725",
      "1000000200471771",
      "1000000200491361"
   ]
}

Here's an Android object:

{  
   "id":"iap_id",
   "alias":"Subscription 20",
   "type":"paid subscription",
   "state":"approved",
   "title":"Standard Subscription",
   "description":"Standard Subscription",
   "price":"$19.99",
   "currency":"USD",
   "loaded":true,
   "canPurchase":false,
   "owned":false,
   "downloading":false,
   "downloaded":false,
   "transaction":{  
      "type":"android-playstore",
      "purchaseToken":"bhgenijimhhgenhadngmajnp.AO-J1OxzqrUBfYXMJinFFjbRSUhL6E7bcbfnp0uZpEWi_ziPiimWbFt4n7IjRMN_1_yrP5m0jVI5l0t9OzfhsfLGyoJ-5E1ey9KLewlEGEGBM_B4EbinjZ5tWTrl",
      "receipt":"{\"packageName\":\"com.package.first\",\"productId\":\"iap_id\",\"purchaseTime\":1458232471621,\"purchaseState\":0,\"purchaseToken\":\"bhgenijimhhgenhadngmajnp.AO-J1OxzqrUBfYXMJinFFjbRSUhL6E7bcbfnp0uZpEWi_ziPiimWbFt4n7IjRMN_1_yrP5m0jVI5l0t9OzfhsfLGyoJ-5E1ey9KLewlEGEGBM_B4EbinjZ5tWTrl\",\"autoRenewing\":false}",
      "signature":"PmKBJWBlVcIg//lZuMaG0zIEQZMcPrJjPUipJ/m0Ccm69mAmh1nPNyy6/Du6FMDEWijEI9jpbnQjLz4/bWBuqjr2CCLImcBFnHkA+ZvslDlh5ZzjwxtC7kD6PwuOMlelqS82JhIRMv1ZwxIYdEA8+Y5XiIClmJ5qvtCcgjU8b2HXDy3lIj5GfWCXJkoE0BMVHLJZemTK4asB5VzxU2xbUrk6ugBmc5jJ0LdlDue12NhFI62edhZoMhOoWd7TJP+IadUb8fIUb4AGct3zI5ccM1pHrzwvUuU0VWxLUs5qr2zCNkz4kw=="
   },
   "valid":true
}

The iOS IAP is missing tons of data. What's going on?

Pigsnuck commented 8 years ago

I think I can help you here. It's irrelevant what is in the product.transaction field, because that's not the information you validate on your server.

When you initialize the store, you need to set the validation URL. Eg: store.validator = "http://xxx/api/verify-app-store-receipt";

The validation URL will be called by the store when you call store.verify(), which you need to do when the store object reports that the product is approved. The verify method is optional, but apparently must be implemented if you have a subscription.

store.when("product").approved(function (product) {
    product.verify();
});

As soon as you call the verify() method, your server API will be called with the full receipt from the App Store (at least it is for me). Does that work for you?

But here is something that needs clarification...

The receipt sent to the verify method contains ALL the transactions made by the user for the app. The problem is this: assume the user has subscribed to 2 different subscription products for your app. One has expired and the other is valid, but the verify method can only return a single VALID or EXPIRED response. Do you know how to send the correct response?

Maybe we can work out the whole validation / verification process together. ;)

caleb87 commented 8 years ago

Pigsnuck, thanks for the help. :)

I saw in the documentation that product.finish() can be called when approved is called so I did that just to try to get it working. Turns out, finish() cannot be called when approved is fired.

I just overrided the validator method like below. When i did that, the product had all the details for iOS to do receipt validation. Also, finish() would fire and work as well. Everything became owned.

store.validator = function(product, callback) {
   // sent request to server with product details....... if checks out....
  product.finish();

}

I only offer one subscription, so hurray I don't have to sort between subscriptions. I simply sorted them all by the expires_date, and picked the farthest one.

Now I'm stuck on how to cancel subscriptions easily for my customers.

Google luckily has an API! Wow. Except it doesn't work.....

GET subscription information works: https://www.googleapis.com/androidpublisher/v2/applications/packageName/purchases/subscriptions/subscriptionId/tokens/token

CANCEL fails because of "invalid value": https://www.googleapis.com/androidpublisher/v2/applications/packageName/purchases/subscriptions/subscriptionId/tokens/token:cancel

Ever deal with this API Pigsnuck?

Pigsnuck commented 8 years ago

I haven't dealt with this API before, sorry, but my understanding was that you were trying to validate iOS receipts?

Just to make sure we're on the same page, are you calling the App Store API from your validation server to validate the encrypted receipt? This is the whole point of doing the validation on the server. The process is as follows:

client sends encrypted receipt to your validation API
          ↓
Validation API sends encrypted receipt to App Store API for validation
          ↓
App Store returns JSON response.
          ↓
Your service checks everything is OK (see *), and returns VALID or EXPIRED

So Apple does the actual validation (a fairly complex process algorithm). You DON'T just want to believe whatever JSON receipt your client is sending you, because this information can be easily spoofed.

(*) The checks you need to make (as far as I know) are:

  1. status=0 (means that Apple has validated the receipt, and everything checks out)
  2. bundle_id is for your app (means that no-one is using a valid receipt meant for another app)
  3. The subscription hasn't expired. To do this, it's insufficient to simply check the last transaction, you need to check that DateTime.UtcNow is between the start and end subscription dates of at least one of the transactions.

Regarding cancelling subscriptions, I haven't crossed that bridge yet, but wouldn't the receipt then have a cancelled transaction, resulting in check 3 expiring the subscription?

caleb87 commented 8 years ago

Right that's what I did. I send the receipt to my server, the server sends to Apples servers, and I get the response. I use that receipt to do the determination. I'm not as dumb as I look ;)

The appStoreReceipt from the app isn't even capable of being parsed without hand-made parsing function. The contents of it aren't actually JSON.

I never actually considered someone sending a valid receipt from a different app. If one of my customers actually did this, I almost would give them the subscription for the effort. Though, I'll check it anyways haha. Edit: This wouldn't work for subscriptions though, since the shared secret would be incorrect.

I just found it easier to overwrite the validator method so I know what's going on. The docs didn't clearly state what the expected server response is supposed to be in order for the validator to fire a success.

Off topic, but I wonder how Apple will treat the Restore process. AFAIK, this plugin will "restore" (simply redo the approval/verify/finish process) when store.refresh() is called. If this is done automatically in the background, I wonder if Apple will allow this or if they demand a button?

Also if anyone doing subscriptions needs to know.... External Link you can add in your app so customers can easily manage iOS subscription: https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/manageSubscriptions

Google's Server-Side API: https://developers.google.com/android-publisher/api-ref/purchases/subscriptions/

I'm not sure if Android has a link to open the subscription manager.

Pigsnuck commented 8 years ago

OK, sounds like you have a robust implementation, then.

Regarding the receipt from a different app: This is a standard hack attempt used by automated tools. If it works, and a script kiddie puts the cracked app on the Cydia or Google Play clone store, then EVERYONE gets it for free. I found that out the hard way with an old version of my app, Paragliding Map for Android. Luckily I could switch that version off from the server after I released an update.

And this attack does work for a subscription, because the shared secret is ONLY on the server (I hope, for your sake!). Keeping the secret an actual secret is one of the main advantages of using a server for validation, as it's impossible to keep secrets on the device, because people can de-obfuscate and unpick the code. ಠ_ಠ

I have tested the restore process. For subscriptions, the receipt is simply replayed. Normally it is stored on the device, so for new devices the receipt is simply re-sent from the App Store. I have an extra "restore" button as you surmised.

Regarding the correct responses for the validation, I looked in the store-ios.js file to find out exactly what is expected and tested each logic fork by sending the different responses from my server. If you like, I can send them to you.