Computer Science
Algorithm
Data Processing
Digital Life
Distributed System
Distributed System Infrastructure
Machine Learning
Operating System
Android
Linux
Tizen
Windows
iOS
Handle Apple In-App-Purchase Server Notification with Scala/Java (2022)
Programming Language
C++
Erlang
Go
Javascript
Scala
Scheme
Type System
Software Engineering
Storage
Virtualization
Life
Life in Guangzhou (2013)
Recent Works (2013)
东京之旅 (2014)
My 2017 Year in Review (2018)
My 2020 in Review (2021)
十三年前被隔离的经历 (2022)
A Travel to Montreal (2022)
My 2022 in Review (2023)
Travel Back to China (2024)
Projects
Bard
Blog
RSS Brain
Scala2grpc
Comment Everywhere (2013)
Fetch Popular Erlang Modules by Coffee Script (2013)
Psychology
耶鲁大学心理学导论 (2012)
Thoughts
Chinese
English

Handle Apple In-App-Purchase Server Notification with Scala/Java

Posted on 20 Aug 2022, tagged iOSAppleIn App PurchaseScalaJavaProgramming

When you write an app for iOS, publish it to Apple App Store and want to sell something within it, Apple makes it mandatory to use its own in app purchase framework for non consumable items and subscriptions. If the app has a server, it’s very usual that the server wants to know the payment events and have some followup logic with them. But how to do that? There is always an option to let the app send a request to server, but anyone can use the same endpoint to make false claims. To prevent this, Apple has a server to server notification mechanism: Instead the app itself, a server from Apple will send a request to your server to notify the payment events. Since the message is signed by Apple, you can make sure no one else can fake it by verifying the signature.

While this framework should in theory makes developer’s life easier, the lack of documentation makes it very painful to use. There is also little and often wrong information on the Internet about how to verify the signature, especially for a server written with Java related tech stack. So in this article, I will give an example about how to decode and verify the payment notification messages sent by Apple with Scala. The library we are using is Nimbus JOSE + JWT which is written in Java, so the method applies to other JVM languages as well. Hopefully this can help other developers who are facing the same problem.

1. How to Trigger a Server Notification

Needless to say, to receive a payment notification you must initiate a payment from the app. There are various ways to do it and we will not discuss it in this article. But be aware there are two ways to test the in app payment: create a StoreKit configuration in Xcode or use a sandbox environment. Only the later one will trigger a server to server notification.

Another requirement is to set up the notification endpoint in Apple Connection settings. The endpoint is a https URL that Apple will send a http post request to. Here is the Apple document about how to do it.

2. Server Notification Workflow

In order to better understand how to handle the notification message, let’s take a look at the server notification workflow first.

iap-server-notification

When Apple receives some payment information, whether it’s from the app, or from subscription renew, or subscription expiration or other events, it will try to send the event to your server by sending an http POST request. In order to make sure no other people can fake a request to the same http endpoint, Apple signs the request payload with a private key that only Apple has access, so that when your server received the message, you can verify it by Apple’s public key.

The way Apple signs the message is using a standard called JWS. This is a complex standard with multiple implementation options. To fully understand it you also need to know things like JWA and JWK. Apple has very little document about how to decode its own message other than throw this RFC page into the document. Even in support forums, their response is like “use your favourite crypto library”, which doesn’t really help anything.

3. JWS Overview

To make it easy, I will give a very simple overview of JWS, JWS has three parts: a header that contains metadata like keys and algorithm to use, the actual payload, and a signature:

header (metadata)

-----------

payload (actual message we want, base64 encoded JSON)

-----------

signature (generated by applying crypto algrithm on payload with keys in header)

So in order to make sure the whole message is actually sent by Apple, we need to verify:

  • The signature is generated by the keys in header and the payload.
  • The keys in header is generated by Apple.

Since the payload is only base64 encoded, for a developer that is not familiar with JWS, even with the help of a JWS library, both verification steps can be easily missed since it only affects the verification, not the decoding of the message.

In the section next, we will have an example about how to decode the message while really verify the message is sent by Apple as well.

4. Decode and Verify Notification

Here we are using App Store server notifications v2. Let’s say you’ve already got the http POST body from your configured endpoint:

val responseBodyV2Str: String = ... // your code to get POST body from HTTP request

responseBodyV2Str itself is not a JWS object but a JSON string like this:

{"signedPayload":"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTU...."}

The value of signedPayload is the encoded JWS string we want to parse. So we need to get that value first. I’m using circe to parse the JSON string, but any method that can parse it and get the value is fine. In my case, I defined some classes based on the JSON structures so that we can parse them in a more type safe way.

import io.circe.generic.extras._
import io.circe.parser


object ApplePaymentService {

  implicit val config: Configuration = Configuration.default.withDefaults

  @ConfiguredJsonCodec case class AppleResponseBodyV2(
    signedPayload: String,
  )

  @ConfiguredJsonCodec case class AppleResponseBodyV2DecodedPayload(
    notificationType: String,
    subtype: Option[String],
    data: ApplePayloadData,
  )

  @ConfiguredJsonCodec case class ApplePayloadData(
    appAppleId: Option[String],
    bundleId: String,
    bundleVersion: String,
    environment: String,
    signedRenewalInfo: String,
    signedTransactionInfo: String,
  )

  @ConfiguredJsonCodec case class AppleJWSTransactionDecodedPayload(
    appAccountToken: String,
    bundleId: String,
    environment: String,
    expiresDate: Long, // timestamp in ms
    inAppOwnershipType: String,
    originalPurchaseDate: Long, // timestamp in ms
    originalTransactionId: String,
    productId: String,
    purchaseDate: Long, // timestamp in ms
    quantity: Int,
    transactionId: String,
    `type`: String,
    webOrderLineItemId: String,
  )
}

With the help with the classes and JSON parser, we can get the value of signedPayload. (I removed \n from the JSON string since it’s not valid to have newlines in JSON string, not sure why Apple’s request body has newline in it):

val responseBodyV2 = parser.parse(responseBodyV2Str.replace("\n", "")).flatMap(_.as[AppleResponseBodyV2]).toTry.get

After get the JWS string, we can parse it with Numbus Jose + JWT (follow the document to add this dependency into your project first):

import com.nimbusds.jose.JWSObject
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton
import com.nimbusds.jose.jwk.ECKey
import com.nimbusds.jose.util.X509CertUtils


val jwsObject = JWSObject.parse(responseBodyV2.signedPayload)
val jwsCerts = jwsObject.getHeader.getX509CertChain.asScala.map(c => X509CertUtils.parse(c.decode()))

4.1 Verify keys in JWS header is signed by Apple

jwsCerts is a list of X509Certificate, which is the key chain in JWS header. A cert in the list can be verified by the cert behind it. And the last cert should be verified by Apple’s public key so that we can make sure the whole key chain is signed by Apple.

So let’s first get the root cert of Apple first: download Apple Root CA - G3 Root from Apple PKI website and put it under your project’s src/resources/certs (or any where the program can read, we are just using it as an example here). Then we can read the Apple root cert with this code:

val appleRootCa = X509CertUtils.parse(getClass.getResourceAsStream("/certs/AppleRootCA-G3.cer").readAllBytes())

With both the key chain and root cert, we can verify the key chain is both valid and signed by Apple:

jwsCerts.sliding(2).foreach { x =>
  x.head.verify(x.last.getPublicKey)
}
jwsCerts.last.verify(appleRootCa.getPublicKey)

It will throw exception if the verify doesn’t pass.

4.2 Verify JWS is signed by keys in JWS header

Once we verified the keys in JWS header is signed by Apple, we need to verify JWS itself is signed by these keys. Since the alg field in this JWS header is ES256, we will use ECDSAVerifier to verify it:

val jwk = ECKey.parse(jwsCerts.head)
val jwsVerifier = new ECDSAVerifier(jwk)
if (!jwsObject.verify(jwsVerifier)) {
  throw new RuntimeException("Apple JWS object cannot be verified")
}

4.3 Parse the payload

After verify the JWS is valid, we can start to parse the payload. I’m using the JSON parser and the structure I defined above. Please refer to Apple’s document about the actual fields in the payload:

val responseBodyV2Payload = jwsObject.getPayload.toString
val responseBodyV2DecodedPayload = parser.parse(responseBodyV2Payload).flatMap(_.as[AppleResponseBodyV2DecodedPayload]).toTry.get
val transactionPayload = responseBodyV2DecodedPayload.data.signedTransactionInfo
val transactionDecodedPayloadStr = JWSObject.parse(transactionPayload).getPayload.toString
val transactionDecodedPayload = parser.parse(transactionDecodedPayloadStr).flatMap(_.as[AppleJWSTransactionDecodedPayload]).toTry.get

Here we have the detailed transaction information in transactionDecodedPayload and can hand it with our business logic.

There is an interesting thing: signedRenewInfo and signedTrasactionInfo are both encoded with JWS again in payload data. I don’t know why: since we’ve already verified the whole payload is signed by Apple, all the content in it should already be valid as well, what’s the point to sign the fields again? I just decoded the fields with JWSObject.parse but you can always verify it with the same method above just to be safe.

5. Other Thoughts

As I said about a previous blog about an iOS bug, I really hate Apple’s close ecosystem. But Apple’s hardware is good and has a large user space, so we cannot avoid it. Hopefully Android can be better at permission management and other mobile OS can also catch up.