Skip to content

Instantly share code, notes, and snippets.

@reiterate-app
Created May 7, 2024 23:14
Show Gist options
  • Save reiterate-app/4652610535b0e317f2353dd33dacd1d7 to your computer and use it in GitHub Desktop.
Save reiterate-app/4652610535b0e317f2353dd33dacd1d7 to your computer and use it in GitHub Desktop.
App Store Receipt Validation
# frozen_string_literal: true
require 'net/http'
class ApiController < ApplicationController
SANDBOX_RECEIPT_URL = 'https://sandbox.itunes.apple.com/verifyReceipt'
PRODUCTION_RECEIPT_URL = 'https://buy.itunes.apple.com/verifyReceipt'
# Here is an example of an endpoint you can call from your app
# StoreKit2
# Inputs: transaction_id (transaction id)
# identifier (product id)
def purchase
if valid_purchase? && (iap_transaction = record_iap_transaction params[:transaction_id])
record_valid_cheer_purchase iap_transaction
else
invalid_transaction
end
end
private
# Send our receipt to the given Apple server for validation, and return Apple's response
def validate_receipt(server_url)
url = URI.parse(server_url)
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
json_request = { 'receipt-data' => params[:receipt] }.to_json
logger.info "Sending validation request to #{server_url}"
http.post(url.path, json_request, { 'Content-Type' => 'application/x-www-form-urlencoded' })
end
def invalid_transaction
render json: { error: 'Invalid transaction' }, status: :unauthorized
end
# Using new App Store Server API to validate cheer transactions
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
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 == user_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
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[:identifier], sort: 'DESCENDING'
Net::HTTP.get_response uri, 'Authorization': "Bearer #{developer_jwt}"
end
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: 'com.playprocyon.autocoach2'
}
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
def app_store_server_host
if sandbox_environment?
'api.storekit-sandbox.itunes.apple.com'
else
'api.storekit.itunes.apple.com'
end
end
def record_iap_transaction(transaction_id)
# Save and check this transaction ID to prevent a replay attack
iap_transaction = IAPTransaction.create(iap_transactionid: transaction_id)
if iap_transaction.invalid?
error = iap_transaction.errors.messages[:iap_transactionid][0]
if error == 'transaction already seen'
logger.info('Replay attack detected!')
render json: { error: 'Duplicate receipt' }, status: :unauthorized and return
else
logger.info("Unknown transaction error (#{error})")
render json: { error: }, status: :unauthorized and return
end
end
iap_transaction
end
end
# The schema I use has two parts. First, there's a table where I record all my product ID's
# (these should be copied from your IAP products page on App Store Connect)
# I also have a table where I record all transaction IDs, to prevent against replay attacks.
# Table of products. Your needs may differ
create_table "products", force: :cascade do |t|
t.string "name"
t.string "description"
t.string "iconFilename"
t.string "productIdentifier"
t.datetime "created_at", precision: nil, default: "2018-08-14 00:07:23", null: false
t.datetime "updated_at", precision: nil, default: "2018-08-14 00:07:23", null: false
t.string "subtitle"
end
# Table for tracking transactions. This is optional. If you don't care about replay attacks you don't need this.
create_table "iap_transactions", force: :cascade do |t|
t.string "iap_transactionid"
t.integer "app_transaction_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["app_transaction_id"], name: "index_iap_transactions_on_app_transaction_id"
t.index ["iap_transactionid"], name: "index_iap_transactions_on_iap_transactionid", unique: true
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment