Created
May 7, 2024 23:14
-
-
Save reiterate-app/4652610535b0e317f2353dd33dacd1d7 to your computer and use it in GitHub Desktop.
App Store Receipt Validation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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