Open8

StoreKit 2を利用したiOSのアプリ内課金の実装について学ぶ(含む、JWTなど)

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

概要

  • 消耗型アイテムを購入する実装について
  • iOS開発自体初学者なので基礎的なところから
  • StoreKit 2で実装
  • サブスクリプションについては考えていません、消耗型だけ
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

サンプルコードを触りながらの学び

purchase周りの公式のサンプルコード
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
    switch verificationResult {
    case .verified(let transaction):
        // Give the user access to purchased content.
        ...
        // Complete the transaction after providing
        // the user access to the content.
        await transaction.finish()
    case .unverified(let transaction, let verificationError):
        // Handle unverified transactions based 
        // on your business model.
        ...
    }
case .pending:
    // The purchase requires action from the customer. 
    // If the transaction completes, 
    // it's available through Transaction.updates.
    break
case .userCancelled:
    // The user canceled the purchase.
    break
@unknown default:
    break
}

WWDC動画からの学び

  • JWSからの検証事項
    • ペイロードから取得したバンドルIDがAppのバンドルIDと一致するかどうか
    • deviceVerificationを行う
  • OriginalTransactionIdは重要で保存しておく必要がある。
  • 消耗型アイテム購入時のサーバーとのやりとり
    • Appからサーバーに対して、以下2つから選択可能
      • Signed Transaction InfoをApp上で検証し、OriginalTransactionIdやその他の情報をサーバーに送信し、受け取ったサーバーがDBに保存する。
      • Signed Transaction Infoを直接サーバーに送信し、検証させる。その後サーバーがDBに保存する。
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

1から実装してみた

  • 消耗型のTicketを買って、貯めるだけ。
    • Ticketの消費までは実装していない。
  • (Swiftで書いたのほぼほぼ初めてでサンプルプロジェクトを写経しつつやりました、、、)
スクリーンショット
  • 以下のスクショはシミュレーターだが、実機でのSandboxでの動作確認も成功することを確認済み



実装

DaraSandboxApp.swift
import SwiftUI

@main
struct DaraSandboxApp: App {
    var body: some Scene {
        WindowGroup {
            StoreView()
        }
    }
}
StoreView.swift
import SwiftUI
import StoreKit

struct StoreView: View {
    @StateObject var store: Store = Store()
    
    var body: some View {
        VStack {
            Text("チケット購入画面")
            ForEach(store.tickets) { ticket in
                TicketListCellView(ticket: ticket)
            }
        }
        .environmentObject(store)
    }
}

struct StoreView_Previews: PreviewProvider {
    static var previews: some View {
        StoreView()
    }
}
TicketListCellView.swift
import SwiftUI
import StoreKit

enum TicketKey: String {
    case ticketDara1 = "consumable.ticketdara.1"
}

struct TicketListCellView: View {
    @EnvironmentObject var store: Store
    
    @AppStorage(TicketKey.ticketDara1.rawValue) var ticketDara1 = 0
    
    let ticket: Product
    
    init(ticket: Product) {
        self.ticket = ticket
    }
    
    var body: some View {
        HStack {
            let ticketAmount = amount(for: ticket)
            Text("チケット")
            buyButton
            Text("使用可能 \(ticketAmount)枚")
        }
    }
    
    fileprivate func amount(for ticket: Product) -> Int {
        switch ticket.id {
        case TicketKey.ticketDara1.rawValue: return ticketDara1
        default: return 0
        }
    }
    
    fileprivate func storeConsumable(_ purchasedTicket: Product) {
        let availableTicket = UserDefaults.standard.integer(forKey: purchasedTicket.id)
        UserDefaults.standard.set(availableTicket + 1, forKey: purchasedTicket.id)
    }
    
    var buyButton: some View {
        Button(action: {
            Task {
                await buy()
            }
        }) {
            Text(ticket.displayPrice)
        }
    }
    
    func buy() async {
        do {
            if try await store.purchase(ticket) != nil {
                storeConsumable(ticket)
            }
        } catch {
            print("Failed")
        }
    }
}
Store.swift
import Foundation
import StoreKit
import CryptoKit
import CommonCrypto

typealias Transaction = StoreKit.Transaction

public enum StoreError: Error {
    case failedVerification
}

class Store: ObservableObject {
    @Published private(set) var tickets: [Product]
    @Published private(set) var purchasedTickets: [Product] = []
    let productList: [String: String]
    
    var updateListenerTask: Task<Void, Error>? = nil
    
    init() {
        tickets = []
        productList = Store.loadProductList()
        
        updateListenerTask = listenForTransactions()
        
        Task {
            await requestProducts()
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            //Iterate through any transactions that don't come from a direct call to `purchase()`.
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)

                    //Always finish a transaction.
                    await transaction.finish()
                } catch {
                    //StoreKit has a transaction that fails verification. Don't deliver content to the user.
                    print("Transaction failed verification")
                }
            }
        }
    }
    
    static func loadProductList() -> [String: String] {
        guard let path = Bundle.main.path(forResource: "Products", ofType: "plist"),
              let plist = FileManager.default.contents(atPath: path),
              let data = try? PropertyListSerialization.propertyList(from: plist, format: nil) as? [String: String] else {
            return [:]
        }
        return data
    }
    
    
    @MainActor
    func requestProducts() async {
        do {
            let storeProducts = try await Product.products(for: productList.keys)
            
            var newTickets: [Product] = []
            
            for storeProduct in storeProducts {
                if storeProduct.type == .consumable {
                    newTickets.append(storeProduct)
                }
            }
            
            tickets = newTickets
        } catch {
            print("requestProductsでエラー")
        }
    }
    
    func purchase(_ product: Product) async throws -> Transaction? {
        // resultの型について: https://developer.apple.com/documentation/storekit/product/purchaseresult
        let result = try await product.purchase()
        
        switch result {
        // successの実態はこちら: success(VerificationResult<Transaction>)
        case .success(let verification):
            let transaction = try checkVerified(verification)

            if (!checkDeviceVerified(transaction)) {
                return nil
            }
            
            await transaction.finish()
            return transaction
        case .userCancelled, .pending:
            return nil
        default:
            return nil
        }
    }
    
    // TはTransactionが想定される(?少なくとも上記のpurchaseでは)
    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        //Check whether the JWS passes StoreKit verification.
        switch result {
        case .unverified:
            //StoreKit parses the JWS, but it fails verification.
            throw StoreError.failedVerification
        case .verified(let safe):
            //The result is verified. Return the unwrapped value.
            return safe
        }
    }
    
    func checkDeviceVerified(_ transaction: Transaction) -> Bool {
        guard let deviceVerificationUUID = AppStore.deviceVerificationID else {
            print("Device Verification ID isn't available.")
            return false
        }

        // Assemble the values to hash.
        let deviceVerificationIDString = deviceVerificationUUID.uuidString.lowercased()
        let nonceString = transaction.deviceVerificationNonce.uuidString.lowercased()
        let hashTargetString = nonceString.appending(deviceVerificationIDString)

        // Compute the hash.
        let hashTargetData = Data(hashTargetString.utf8)
        let digest = SHA384.hash(data: hashTargetData)
        let digestData = Data(digest)
        if digestData == transaction.deviceVerification {
            print("Transaction is valid for this device.")
            return true
        } else {
            print("Transaction isn't valid for this device.")
            return false
        }
    }
}
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

アプリ内課金のテスト

XCodeでのテスト

  • XCodeで実施する場合は、ツールバーのProduct > Scheme > Edit Scheme... > RunのOptionタブ > StoreKit Configuratinでローカルにある◯◯.storekitを参照させる

  • こちらの記事にならって、[File] > [New] > [File...] からStoreKit Configuration Fileを作成する。その後、Productを作成する。

  • Product.plistなどを参照させてアプリ内からProduct一覧を入手する

  • このあたりの具体的な実装はAppleのサンプルコードを参考にする。

Sandboxでのテスト

  • App Store Connectの「ユーザーとアクセス」から、Sandboxテスターのアカウントを作成する。
    • 普段自分が使っているアカウントとは別の方が良い模様。AppleIDに紐づかないメアドでOK(というかそっちのほうが良さそう)
    • iPhoneの設定 > AppleStoreから、Sandboxアカウントを設定する。
    • 詳細はこちら: https://zenn.dev/flutteruniv_dev/articles/4a5d40bb1dd7b7
Sandboxアカウントに設定していないアカウントで購入しようとするとエラーとなる

正常な購入が完了したスクショ

だーら(Flamers / Memotia)だーら(Flamers / Memotia)

検証の話

  • アプリから購入した際に返却される値を見てみる
purchaseの戻り値
  • ログを出力する様に記述
    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()
        
+       print("=== This is purchase result, which is enum Product.PurchaseResult ===")
+       print(result)

        switch result {
        // successの実態はこちら: success(VerificationResult<Transaction>)
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await transaction.finish()
           ......
    }

    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        //Check whether the JWS passes StoreKit verification.
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
+           print("=== This is verified transaction ===")
+           print(safe)
            return safe
        }
    }
  • 出力
=== This is purchase result, which is enum Product.PurchaseResult ===
success({
  "header" : {
    "alg" : "ES256",
    "kid" : "Apple_Xcode_Key",
    "typ" : "JWT",
    "x5c" : [
  "MIIBzDCCAXGgAwIBAgIBATAKB...略...25s="
    ]
  },
  "payload" : {
    "bundleId" : "com.xxxx.yyyy",
    "deviceVerification" : "jxOzVih...略...fUHJ6EYWy0",
    "deviceVerificationNonce" : "d75a...略...75f0",
    "environment" : "Xcode",
    "inAppOwnershipType" : "PURCHASED",
    "originalPurchaseDate" : 1699175167738.4683,
    "originalTransactionId" : "28",
    "productId" : "consumable.xxxxxx.1",
    "purchaseDate" : 1699175167738.4683,
    "quantity" : 1,
    "signedDate" : 1699175167739.0151,
    "transactionId" : "28",
    "type" : "Consumable"
  },
  "signature" : "64 bytes (verified)"
})
=== This is verified transaction ===
{
  "bundleId" : "com.xxxx.yyyy",
  "deviceVerification" : "jxOzVi...略...6EYWy0",
  "deviceVerificationNonce" : "d75a...略...5f0",
  "environment" : "Xcode",
  "inAppOwnershipType" : "PURCHASED",
  "originalPurchaseDate" : 1699175167738.4683,
  "originalTransactionId" : "28",
  "productId" : "consumable.xxxxxx.1",
  "purchaseDate" : 1699175167738.4683,
  "quantity" : 1,
  "signedDate" : 1699175167739.0151,
  "transactionId" : "28",
  "type" : "Consumable"
}

deviceVerification

The verifyReceipt endpoint is deprecated

AppTransactionとTransactionってなにが違うの?

Validating receipts on the device

  • Note抜粋

アプリ内購入の検証にTransactionを用いるのであれば、receiptは必要ない。
アプリ内購入のためのオリジナルAPIを利用している場合にはreceiptが必要

  • おそらくアプリ内課金をオリジナルAPIで提供することはなさそうなのでいったん読み飛ばす

検証のフロー

  • App Store Server Libraryの動画が参考になる
  • 以前までの/verifyRecieptはdeprecatedになった
  • 新フローはこちら
  • ライブラリを利用する場合には、RecieptUtility::getTransactinIdFromAppReceiptを利用する模様
  • Receiptは、デバイスまたはApp Store Server Notification V1から取得できる
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

App Store Server APIの呼び出し

  • App Store Server APIを呼び出すためには、JWTが必要(リクエストを認証するため)

プライベートAPIキーについて

API キーには 2 つの部分があります。Apple が保持する公開部分と、ユーザーがダウンロードする秘密キーです。秘密キーを使用して、API が App Store のデータにアクセスすることを承認するトークンに署名します。

JWTの生成について

  • (ペイロードってなに?...宛先などの制御情報を除いた、相手に送り届けようとしている正味のデータ本体のこと。ソース
  • この公式ドキュメントが完璧なので従えばOKそう
  • ↑の公式ドキュメントからも案内されているが、JWTの作成と署名に利用できるライブラリはこちら: https://jwt.io/libraries
  • algの"ES256"は、ECDSAアルゴリズムを使用。P-256 および SHA-256 を使⽤した ECDSA。"P-256" は、使⽤されるアルゴリズムのバージョンを⽰しています。参考
JWTのヘッダーとペイロード例
  • 公式ドキュメントにかかれていた通り
  • ヘッダー例
{
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
  • ペイロード例
{
  "iss": "57246542-96fe-1a63e053-0824d011072a",
  "iat": 1623085200,
  "exp": 1623086400,
  "aud": "appstoreconnect-v1",
  "bid": "com.example.testbundleid2021"
}
だーら(Flamers / Memotia)だーら(Flamers / Memotia)

Rubyで実装

実装コード
require 'net/https'
require 'json'
require 'uri'
require 'jwt'

# Constants
KID = 'xxxxxxxx'
KEY_FILENAME = 'SubscriptionKey_xxxxxxxx.p8'
TRANSACTION_BASE_URL = 'https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/'
ISS = 'xxxxxxxx'
AUD = 'appstoreconnect-v1'
BID = 'com.xxxxx.xxxxx'

# Variables
transaction_id = 'xxxxxxxx'
endpoint = TRANSACTION_BASE_URL + transaction_id

# Payload
iat = Time.now.to_i
exp = iat + 60 * 10
payload = {
  iss: ISS,
  iat: iat,
  exp: exp,
  aud: AUD,
  bid: BID,
}

# JWT Token
ecdsa_key = OpenSSL::PKey::EC.new IO.read(KEY_FILENAME)
token = JWT.encode payload, ecdsa_key, 'ES256', header_fields = { kid: KID }

# Request
uri = URI.parse(endpoint)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
req = Net::HTTP::Get.new(uri.request_uri)
req["Authorization"] = "Bearer #{token}"
req["Content-Type"] = "application/json"

begin
  res = http.request(req)
  result = JSON.parse(res.body)
rescue IOError => e
  puts 'IOError:'
  puts e.message
rescue TimeoutError => e
  puts 'TimeoutError:'
  puts e.message
rescue JSON::ParserError => e
  puts 'ParserError:'
  puts e.message
rescue => e
  puts 'Error:'
  puts e.message
end

puts '【result】'
puts "Code: #{res.code} #{res.msg}"
puts "Body: #{res.body}"
puts '-----'

# Decode
result_json = JSON.parse(res.body)
decoded_token = JWT.decode result_json['signedTransactionInfo'], ecdsa_key, false, { algorithm: 'ES256' }

puts 'Decoded:'
decoded_token.each do |e|
  puts '-----'
  puts e
end
  • なお、最後の方の以下の部分の引数をtrueにするとエラーが発生するのは、自分が署名したものではないからだろうか?
    decoded_token = JWT.decode result_json['signedTransactionInfo'], ecdsa_key, false, { algorithm: 'ES256' }
    エラー内容
/usr/local/bundle/gems/jwt-2.7.1/lib/jwt/decode.rb:49:in `verify_signature': Signature verification failed (JWT::VerificationError)
  • 自分がリクエストする際に生成するJWT(token変数)は、第3引数をtrueにしてもdecodeできる