Transaction Verification with the App Store Server API and Rails

03 Jul 2022
11 minute read

Apple released the new StoreKit 2 API, and I’ve been working on updating Reiterate to use it.

While Apple is still supporting version 1 of StoreKit, they have stopped supporting Apple-hosted assets for non-consumable products, so all future products for Reiterate will have to use a new purchasing flow. Since I’m updating that, I might as well move everything over to the new API.

There’s two parts to the in-app purchase flow, the client side (on your phone), and the server side (on this server, right here!). When you make a purchase, the Reiterate app communicates the transaction to Apple via StoreKit. Apple verifies payment, and sends the verification back to the app, which then sends it to my own server for recording and additional verification. All sensitive data, like account balances, are kept on the server side, and the server acts as a “single source of truth”.

When the server receives a purchase transaction from a client, it then forwards that transaction to Apple for additional verification (otherwise, a hacked client could send fake purchases and get free stuff that way). The verification is handled through the new App Store Server API, which has also been updated from the old “Original API for In-App Purchase” (that’s its new official name, apparently).

The new API uses JSON Web Tokens to authenticate all transactions. The JWT are used on both sides: you need your own JWT to authenticate yourself to Apple’s servers, and Apple returns its own JWT which you can authenticate on your own to verify the response is indeed from Apple.

Step 1: Create your signing keys

First, you need to create the keys that you will use to sign your own requests that you send to Apple. This is done via App Store Connect, under Users > Keys > In-App Purchase. Create a new key, then download it (it only gives you one chance to download). The key is just a text file; you can copy and paste it easily (and I did just that in the next step).

In addition to the key, you need the key id which is the 10-‘digit’ alphanumeric ID on the table, and your issuer id, which is on the other page. This is a little obscure, but you have to click on App Store Connect API to switch to that page, and then you will see your issuer ID, which is a standard UUID. Or, maybe you won’t see it. For some reason Apple doesn’t give you an issuer ID until you’ve made at least one App Store Connect API Key, so make one of those if you have never done that. You’re not actually going to use the App Store Connect Key; you just need one in your account in order to get an issuer id.

Step 2: Add your key to your credentials file

For a Rails app, the proper place to store things like signing keys is in the credentials file. You can edit the file and add your key info like so:

$ bin/rails credentials:edit

# Your credentials file will look different
secret_key_base: 8485794notmyrealsecretkey907349287429
apple:
  iap_key: |-
    -----BEGIN PRIVATE KEY-----
    HWIHF83HFDiiuhrwgo8773jh83hbeNOT-MY-REAL-SECRET-KEYjgrgjgrjo844r
    83HFDiiuhrwgo8773jh83hbeNOT-MY-REAL-SECRET-KEYjgrg83HFDiiuhrwgo8
    773jh83hb3l6Jg83HFDiiuhrwgo8773jh83hbeNOT-MY-REAL-SECRET-KEYjgrg
    he83j29
    -----END PRIVATE KEY-----
  key_id: ALSOFAKE87
  # from https://appstoreconnect.apple.com/access/api
  issuer_id: 4839434234-3432-ab3433b-93deadbeef

Note the use of the |- syntax. Yaml has many different ways of handling multi-line strings. This one preserves the line breaks in the middle of the string, but doesn’t add one at the end, which is how we want it for the private key.

Step 3: Download Apple’s root certificate

The root certificate forms the foundation of trust for the entire trust chain. The JWT is signed by a sequence of keys that are all given in the same response, but the last key should be the root key. If we have our own copy of the root key, then we can be sure it came from Apple.

Apple displays all its root certificates on its Public Key Infrastructure page. You can download all the root certificates, but the only one we seem to need for JWT authentication is the “G3 Root”.

I created a directory at config/certificates/apple and downloaded the certificate there.


Server side Rails code

Now we should have all the data we need to send and receive authenticated messages with the App Store Server API. Here is the relevant code I use to verify transactions.

Step 4: Initialize the Apple root certificate

I use an initializer to take the certificate I downloaded from Apple’s PKI, and turn it into an actual working certificate. It’s done in an initializer so I only have to do this once, at start up

# In config/initializers/certificates.rb

module Certificates
  Apple = Rails.root.join('config', 'certificates', 'apple').each_child.collect do |filename|
    OpenSSL::X509::Certificate.new File.read(filename)
  end
end

If you have multiple root certificates in there, this will process all of them.

Step 5: Handle purchase requests

When a user purchases cheer in the app, the app will process the transaction with the StoreKit 2 API, and if that comes through okay then it sends the transaction to my server for recording and additional verification. The Rails controller is pretty simple.

Sending the request, with a signed JSON Web Token

  def purchase
    if valid_purchase? && (iap_transaction = record_iap_transaction)
      record_valid_cheer_purchase iap_transaction
    else
      invalid_transaction
    end
  end

I’ll break down each of these methods, but first there’s some utility code. When we send a request to Apple, it has to be signed with our own JWT. This method generates that for us by taking the info we saved in the credentials file, timestamping it, and then encrypting it all into a token.

  def developer_jwt
    payload = {
      iss: Rails.application.credentials[:apple][:issuer_id],
      iat: Time.now.to_i,
      exp: Time.now.advance(minutes: 10).to_i,
      aud: 'appstoreconnect-v1',
      bid: 'your.bundle.id'
    }
    header = {
      alg: 'ES256',
      kid: Rails.application.credentials[:apple][:key_id],
      typ: 'JWT'
    }
    private_key = OpenSSL::PKey::EC.new Rails.application.credentials[:apple][:iap_key]
    JWT.encode payload, private_key, 'ES256', header
  end

Note this uses the jwt gem, so make sure you add that to your Gemfile.

Next is a method that selects the proper host, depending on whether or not we are in a sandbox environment. You will probably have your own method of determining this, or maybe you just hardcode it.

  def app_store_server_host
    if sandbox_environment?
      'api.storekit-sandbox.itunes.apple.com'
    else
      'api.storekit.itunes.apple.com'
    end
  end

Now we come to the real bits. This next method makes the call to the Apple Store API. It gets a token from our developer_jwt method and puts that in the header of the request. If you don’t do that, then you’ll get an Unauthorized response back from the API.

We’re calling the history endpoint, which in my mind is a little strange. You pass a transaction id to the endpoint as part of the URL, but what it returns is not just that transaction. Instead, it looks up the account that that transaction belongs to, and gives you a list of every transaction for that account. In fact, if you pass in parameters to filter the results, it’s possible you won’t get the transaction you asked for at all!

Nonetheless, this seems to be the only way to look up a transaction, so we’ll use it.

  def transaction_history
    uri = URI.parse "https://#{app_store_server_host}/inApps/v1/history/#{params[:transaction_id]}"
    uri.query = URI.encode_www_form productId: params[:product_id], sort: 'DESCENDING'
    Net::HTTP.get_response uri, 'Authorization': "Bearer #{developer_jwt}"
  end

Note that we’re filtering by product_id, which the app passes in to our endpoint. And we also filter in descending order, so it will return the most recent transaction first. This should, in theory, return the transaction we’re looking for as the first transaction in the list.

Verifying the purchase transaction

Once we have a response back from the App Store Server, we need to check it to make sure the transaction is validated.

  def valid_purchase?
    response = transaction_history
    response.is_a? Net::HTTPSuccess or raise Reiterate::AppStoreError, "App Store server returned #{response.code}"
    purchase_transaction = JSON.parse(response.body)['signedTransactions']&.first
    raise Reiterate::AppStoreError, 'Transaction missing' unless purchase_transaction

    authenticate_transaction purchase_transaction
  rescue Reiterate::AppStoreError => e
    logger.warn "Invalid purchase: #{e.message}" and return false
  end

The valid_purchase? method gets the transaction_history and first of all checks that the App Store Server returned with an OK status. If so, we grab the first transaction out of the response (which we assume is the transaction that we are verifying). This transaction is actually another JSON Web Token, except this one is from Apple, so we need to unpack it and verify the signature. That’s done in the authenticate_transaction method.

  def authenticate_transaction(jwt)
    payload, = JWT.decode(jwt, nil, true, algorithm: 'ES256') do |header|
      certs = header['x5c'].map { |c| OpenSSL::X509::Certificate.new Base64.urlsafe_decode64(c) }
      Certificates::Apple.include? certs.last or raise JWT::DecodeError, 'Missing root certificate'
      certs.each_cons(2).all? { |a, b| a.verify(b.public_key) } or raise JWT::DecodeError, 'Broken trust chain'

      certs[0].public_key
    end
    payload['appAccountToken'].upcase == account_id.upcase or raise Reiterate::AppStoreError, 'User ID mismatch'
    payload['productId'] == params[:identifier] or raise Reiterate::AppStoreError, 'Product mismatch'
    payload['transactionId'].to_i == params[:transaction_id] or raise Reiterate::AppStoreError, 'Transaction mismatch'
  rescue JWT::DecodeError, Reiterate::AppStoreError => e
    logger.warn "Transaction error: #{e.message}" and return false
  end

authenticate_transaction starts by using the JWT.decode method from the jwt gem. Because the signing certificates are being passed in the header of the token itself, we need to use the block parameter to actually pick out the key that’s used to decrypt the token.

After unpacking the certificates in the header, we perform two validation checks. First, we verify that the root certificate in the header matches the root certificate that we downloaded from Apple. Then, we check that each certificate signs the next one down the chain, all the way to the top certificate which is the one used to decrypt the token. Since we have an unbroken chain from the root certificate to the signing certificate, we can know with confidence that this token indeed came from Apple.

After the payload segment of the token is decrypted, we perform three additional checks to verify authenticity. First, we check that the account ID matches. This is a new feature in StoreKit 2 that really helps bring everything together. When the transaction is created, on the app side, a user account token is generated and added to the transaction. This account ID is passed in to the verification endpoint, from the app, and also from Apple, through the token payload. Checking that these match ensures that the transaction really did come from the account in question.

Next, we check that the product ID is the same (it’s also passed from the app), and the transaction ID matches as well. We’ve assumed all this time that the first transaction in the returned list is the transaction in question, and here we verify that with one additional check. If all these checks pass, then the transaction is considered valid.

Step 6: Check for replays

Let’s look again at the top calling method

  def purchase
    if valid_purchase? && (iap_transaction = record_iap_transaction)
      record_valid_cheer_purchase iap_transaction
    else
      invalid_transaction
    end
  end

If the valid_purchase? method returns true, then record_iap_transaction is called. I’m not going to document this here since it will vary depending on your application, but basically it keeps a record of every purchase transaction and guards against replay attacks. A replay attack is when someone tries to send the same valid transaction multiple times. If I didn’t check for that it might be possible for them to buy an item once, but then they would get multiple credits for it.

That’s the final check. If everything passes, then the purchase is recorded and added to the user’s account.

I might package this up into a gem if there’s any interest for it. I can see some old gems for the old StoreKit, but nothing for the new server. If you’re interested in something like that, please let me know in the comments.

Tagged with

Comments and Webmentions


You can respond to this post using Webmentions. If you published a response to this elsewhere,

This post is licensed under CC BY 4.0