Open8
StoreKit 2を利用したiOSのアプリ内課金の実装について学ぶ(含む、JWTなど)
概要
- 消耗型アイテムを購入する実装について
- iOS開発自体初学者なので基礎的なところから
- StoreKit 2で実装
- サブスクリプションについては考えていません、消耗型だけ
公式doc
- IAPの課金形態の種類について
- 開発面での全体像が書かれたドキュメント
- StoreKit 2での全体像を一番捉えやすい動画(WWDC21の3本構成動画の1本目。サンプルコードと関連している)
- StoreKit 2のアプリ内課金とサーバー実装に関する動画(WWDC21の3本構成動画の2本目)
一般の記事
- Qiita記事
- WWDCの動画をもとにざっくりまとめてくれた記事
- Swiftの書き方をざっくり把握したときに見る記事
- JUTについてとても詳しく、わかりやすい記事
- https://qiita.com/Masataka-n/items/6f98a5a9fee7b28ccd1f
サンプルコード
アプリ内課金とは関係ないSwiftの話とか
サンプルコードを触りながらの学び
- サンプルコードのページにかかれている手順に従い、Schemeを編集し、StoreKitのテストが実行できるようにする。
- WWDC21の動画では、Product一覧を取得する際に
Product.request
を利用していたが自分の環境ではコンパイルエラーとなっている。Product.products
を用いるようにAPIが変更されたか? -
await product.purchase()
の戻り値はenum Product.PurchaseResultである。
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に保存する。
- Appからサーバーに対して、以下2つから選択可能
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
}
}
}
アプリ内課金のテスト
- 参考: https://developer.apple.com/jp/help/app-store-connect/configure-in-app-purchase-settings/overview-for-configuring-in-app-purchases
- アプリ内課金のテストは「XCode」「Sandbox」「TestFlight」の3つの形態で実施できる。
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アカウントに設定していないアカウントで購入しようとするとエラーとなる
正常な購入が完了したスクショ
検証の話
- アプリから購入した際に返却される値を見てみる
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
- トランザクションがデバイスに属しているかを検証。
- レシート検証の追加のセキュリティレイヤーという立ち位置。
- 実際のコード付きドキュメント: https://developer.apple.com/documentation/storekit/transaction/3749690-deviceverification
The verifyReceipt endpoint is deprecated
- https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/validating_receipts_with_the_app_store
- verifyReceiptエンドポイントは廃止予定
- receiptをサーバーで検証するためには、Validating receipts on the deviceに従う
- レシート無しでアプリ内課金を検証するためには、
- App Store Server APIを叩いてAppleに署名されたtransactionやsubscriptionの情報を得る、または、
- アプリが取得したAppTransaction/Transactionの情報を検証する。
- 同じ署名済みのtransaction/subscription情報は、V2のサーバー通知からも取得できる。
AppTransactionとTransactionってなにが違うの?
- 公式doc: https://developer.apple.com/documentation/storekit/apptransaction
- わかりやすく解説された記事: https://zenn.dev/dena/articles/73cca18a93c8e6
- Transactionがアプリ「内」購入であるのに対し、AppTransactionはアプリ「自体」の購入を管理する模様。
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から取得できる
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"
}
Rubyで実装
- https://github.com/jwt/ruby-jwt#ecdsa
- https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info
実装コード
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できる