作者:iHTCboy
關於 App Store 用戶退款時並沒有通知開發者,直到 2020 年 6 月蘋果提供了退款通知,但是因為不是 API 方式,導致開發者不一定能收到退款通知。另外像用戶充值成功但 app 沒有提供金幣或服務等,開發者一般無法判斷用戶是否真的付款了。綜上,蘋果在 WWDC21 帶來了全新強大的 App Store Server API,本文讓我們從了解到實踐的過程,全面認識 App Store Server API。
一、前言
大家好,我們在去年在 WWDC21 后 6 月 17 日發表了總結文章《蘋果iOS內購三步曲 - WWDC21》。當時只是根據蘋果的演講內容進行了梳理,當時的很多接口和功能並沒有上線,比如根據玩家的發票訂單號查詢用戶的蘋果收據,查詢歷史訂單接口等,當時文章並沒有深入的分析,而如今都 2022 年了,蘋果 App Store Server API 已經上線,所以,今天我們一起來了解一下,相關 API 的具體使用實踐吧~
二、App Store Server API
首先,我們先列一下,WWDC21 蘋果提供了那些 Server API,然后我們在看看怎么實踐這些接口,最后在總結一下注意事項。
2.1 API 簡介
查詢用戶訂單的收據
GET https://api.storekit.itunes.apple.com/inApps/v1/lookup/{orderId}
- Look Up Order ID:使用訂單ID從收據中獲取用戶的應用內購買項目收據信息。
查詢用戶歷史收據
GET https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}
- Get Transaction History:獲取用戶在您的 app 的應用內購買交易歷史記錄。
查詢用戶內購退款
GET https://api.storekit.itunes.apple.com/inApps/v1/refund/lookup/{originalTransactionId}
- Get Refund History:獲取 app 中為用戶退款的所有應用內購買項目的列表。
查詢用戶訂閱項目狀態
GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
- Get All Subscription Statuses:獲取您 app 中用戶所有訂閱的狀態。
提交防欺詐信息
PUT https://api.storekit.itunes.apple.com/inApps/v1/transactions/consumption/{originalTransactionId}
Send Consumption Information:當用戶申請退款時,蘋果通知(CONSUMPTION_REQUEST)開發者服務器,開發者可在12小時內,提供用戶的信息(比如游戲金幣是否已消費、用戶充值過多少錢、退款過多少錢等),最后蘋果收到這些信息,協助“退款決策系統” 來決定是否允許用戶退款。
延長用戶訂閱的時長
PUT https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/extend/{originalTransactionId}
Extend a Subscription Renewal Date:使用原始交易標識符延長用戶有效訂閱的續訂日期。(相當於免費給用戶增加訂閱時長)
2.2 接口參數說明
App Store Server API 是蘋果提供給開發者,通過服務器來管理用戶在 App Store 應用內購買的一套接口(REST API)。
URL
線上環境的 URL:
https://api.storekit.itunes.apple.com/
沙盒環境測試:
https://api.storekit-sandbox.itunes.apple.com/
JWT 簡介
調用這些 API 需要 JWT(JSON Web Token
)進行授權。那么什么是 JWT 呢?
JWT
是一個開放式標准(規范文件 RFC 7519),用於在各方之間以 JSON 對象安全傳輸信息。有兩種實現,一種基於 JWS
的實現使用了BASE64URL
編碼和數字簽名的方式對傳輸的Claims
提供了完整性保護,也就是僅僅保證傳輸的Claims
內容不被篡改,但是會暴露明文。另一種是基於 JWE
實現的依賴於加解密算法、BASE64URL
編碼和身份認證等手段提高傳輸的Claims
內容被破解的難度。
JWS
(規范文件 RFC 7515):JSON Web Signature
,表示使用JSON
數據結構和BASE64URL
編碼表示經過數字簽名或消息認證碼(MAC
)認證的內容。JWE
(規范文件 RFC 7516):JSON Web Encryption
,表示基於JSON
數據結構的加密內容。
目前蘋果 JWT 相關的內容,都是基於 JWS 實現,所以下文的 JWT 默認指 JWS。
JWT(JWS) 由三部分組成:
base64(header) + '.' + base64(payload) + '.' + sign( Base64(header) + "." + Base64(payload) )
- header:主要聲明了 JWT 的簽名算法;
- payload:主要承載了各種聲明並傳遞明文數據;
- signture:擁有該部分的 JWT 被稱為 JWS,也就是簽了名的 JWS。
JWT 的內容示例:
eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidGVhbSI6IjM35omL5ri45oqA5pyv6L-Q6JCl5Zui6ZifIiwiYXV0aG9yIjoiaUhUQ2JveSIsImlhdCI6MTUxNjIzOTAyMn0.dL5U_t_DcfLTY9WolmbU-j81jrZqs1HhHqYKM6HSxVgWCGUAKLzwVrnLuuMCnSRnrW9vmGKNqNvrzG8cEwxvAg
詳細的 JWT 內容,這里就略過了,大家可以自動搜索了解更多。
組裝 JWT
知道了基本的 JWT 知識,我們就可以開工啦。要生成簽名的 JWT 有三步:
- 創建 JWT 標頭。
- 創建 JWT 有效負載。
- 在 JWT 上簽名。
JWT header 示例:
{
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
JWT payload 示例:
{
"iss": "57246542-96fe-1a63e053-0824d011072a",
"iat": 1623085200,
"exp": 1623086400,
"aud": "appstoreconnect-v1",
"nonce": "6edffe66-b482-11eb-8529-0242ac130003",
"bid": "com.example.testbundleid2021"
}
以上是蘋果要求的字段規范,所以不同的 JWT 字符和內容並一樣,所以,我們看看蘋果對這些字段的定義:
字段 | 字段說明 | 字段值說明 |
---|---|---|
alg |
Encryption Algorithm,加密算法 | 默認值:ES256。App Store Server API 的所有 JWT 都必須使用 ES256 加密進行簽名。 |
kid |
Key ID,密鑰ID | 您的私鑰ID,值來自 App Store Connect,下文會講解。 |
typ |
Token Type,令牌類型 | 默認值:JWT |
iss |
Issuer,發行人 | 您的發卡機構ID,值來自 App Store Connect 的密鑰頁面,下文會講解。 |
iat |
Issued At,發布時間 | 秒,以 UNIX 時間(例如:1623085200)發布令牌的時間 |
exp |
Expiration Time,到期時間 | 秒,令牌的到期時間,以 UNIX 時間為單位。在iat中超過 60 分鍾過期的令牌無效(例如:1623086400) |
aud |
Audience,受眾 | 固定值:appstoreconnect-v1 |
nonce |
Unique Identifier,唯一標識符 | 您僅創建和使用一次的任意數字(例如: "6edffe66-b482-11eb-8529-0242ac130003")。可以理解為 UUID 值。 |
bid |
Bundle ID,套裝ID | 您的 app 的套裝ID(例如:“com.example.testbundleid2021”) |
其中 kid
和 iss
值是從 App Store Connect 后台創建和獲取。
生成密鑰 ID(kid)
要生成密鑰,您必須在 App Store Connect 中具有管理員角色或帳戶持有人角色。登錄 App Store Connect 並完成以下步驟:
- 選擇 “用戶和訪問”,然后選擇 “密鑰” 子標簽頁。
- 在 “密鑰類型” 下選擇 “App內購買項目”。
- 單擊 “生成API內購買項目密鑰”(如果之前創建過,則點擊 “添加(+)” 按鈕新增。)。
- 輸入密鑰的名稱。該名稱僅供您參考,名字不作為密鑰的一部分。
- 單擊 “生成”。
生成的密鑰,有一列名為 “密鑰 ID” 就是 kid
的值,鼠標移動到文字就會顯示 拷貝密鑰 ID
,點擊按鈕就可以復制 kid 值。
生成 Issuer(iss)
同理,iss
值的生成,類似:
issuer ID 值就是 iss
的值。
生成和簽名 JWT
獲取到這里參數后,就需要簽名,那么還需要簽名的密鑰文件。
下載並保存密鑰文件
App Store Connect 密鑰文件,在剛才生成 kid
時,列表右邊有 下載 App 內購買項目密鑰
按鈕(僅當您尚未下載私鑰時,才會顯示下載鏈接。):
此私鑰只能一次性下載!
另外 Apple 不保留私鑰的副本,將您的私鑰存放在安全的地方。
注意:將您的私鑰存放在安全的地方。不要共享密鑰,不要將密鑰存儲在代碼倉庫中,不要將密鑰放在客戶端代碼中。如果您懷疑私鑰被盜,請立即在 App Store Connect 中撤銷密鑰。有關詳細信息,請參閱 撤銷API密鑰。
API密鑰有兩個部分:蘋果保留的公鑰和您下載的私鑰。開發者使用私鑰對授權 API 在 App Store 中訪問數據的令牌進行簽名。
需要注意的是,App Store Server API 密鑰是 App Store Server API 所獨有的,不能用於其他 Apple 服務(比如 Sign in with Apple 服務或 App Store Connet API 服務等。)。
為 API 請求生成令牌
最終,JWT Header 和 payload 示例:
{
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
{
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"iat": 1623085200,
"exp": 1623086400,
"aud": "appstoreconnect-v1",
"nonce": "6edffe66-b482-11eb-8529-0242ac130003",
"bid": "com.apple.test"
}
有了以上參數和密鑰文件后,我們就可以按 JWT 規范要求來生成 token 值。下面以 Python3
代碼來生成 token,其它語言類型。
首先,終端執行命令,安裝 ptyhon 依賴庫:
pip3 install PyJWT
我們利用 Python 的 PyJWT
庫來生成 JWT token。示例代碼:
import jwt
import time
# 讀取密鑰文件證書內容
f = open("/Users/37/Downloads/SubscriptionKey_2X9R4HXF34.p8")
key_data = f.read()
f.close()
# JWT Header
header = {
"alg": "ES256",
"kid": "2X9R4HXF34",
"typ": "JWT"
}
# JWT Payload
payload = {
"iss": "57246542-96fe-1a63-e053-0824d011072a",
"aud": "appstoreconnect-v1",
"iat": int(time.time()),
"exp": int(time.time()) + 60 * 60, # 60 minutes timestamp
"nonce": "6edffe66-b482-11eb-8529-0242ac130003",
"bid": "com.apple.test"
}
# JWT token
token = jwt.encode(headers=header, payload=payload, key=key_data, algorithm="ES256")
print("JWT Token:", token)
輸出示例:
eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQifQ.eyJpc3MiOiI1NzI0NjU0Mi05NmZlLTFhNjMtZTA1My0wODI0ZDAxMTA3MmEiLCJhdWQiOiJhcHBzdG9yZWNvbm5lY3QtdjEiLCJpYXQiOjE2NDMwMTU1OTQsImV4cCI6MTY0MzAxOTE5NCwibm9uY2UiOiI2ZWRmZmU2Ni1iNDgyLTExZWItODUyOS0wMjQyYWMxMzAwMDMiLCJiaWQiOiJjb20uYXBwbGUudGVzdCJ9.muBKcbT3AnK3WAivbtIr64d2Gu7bVhGL3AhiYnDjb7D3qslHNnASE2EUUuN24jOLsSnLBWkBdwDutl5UU87paw
通過這個腳本就可以生成 token 來請求 App Store Server API 了。當然可以把以上代碼封裝成一個方法,傳入 kid 和 iss 等參數,然后返回 token,這里就略過了。
2.3 接口講解
說了這么多,終於回到下文啦!!!
怎么請求 App Store Server API ?蘋果給出了一個示例:
curl -v -H 'Authorization: Bearer [signed token]'
"https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{original_transaction_id}"
也就是用 JWT 生成的 token,放到 App Store Server API 請求鏈接的 header 部分,key 為 Authorization
,value為 Bearer [signed token]
。
接下來,我們通過 Python 的 requests
來請求 App Store Server API。大家也可以用其它的工具來模擬,比如在線工具或者 Postman 等。
終端執行命令,安裝 ptyhon 依賴庫:
pip3 install requests
import requests
import json
# JWT Token
token = "xxxxx"
# 請求鏈接和參數
url = "https://api.storekit.itunes.apple.com/inApps/v1/lookup/" + "MK5TTTVWJH"
header = {
"Authorization": f"Bearer {token}"
}
# 請求和響應
rs = requests.get(url, headers=header)
data = json.loads(rs.text)
print(data)
查詢結果示例:
{
'status': 0,
'signedTransactions': [
'eyJhbGciOiJFUz.............',
'eyJ0eXAiOiJKV1.............',
'eyJ0eXAiJhbGci.............'
]
}
接下來,下面就不重復展示請求的示例了,主要是講解一下接口作用和返回的數據格式,注意事項等。
查詢用戶訂單的收據(Look Up Order ID)
GET https://api.storekit.itunes.apple.com/inApps/v1/lookup/{orderId}
目前這個 Look Up Order ID 接口只有線上環境的,不支持沙盒環境。因為,這個接口是用戶購買項目后,收到蘋果的發票時,里面有一列叫訂單號 Order ID
,以前是無法與開發者從蘋果獲取到的交易訂單號 transactionId
進行映射關聯,而現在,可以通過這個接口查詢啦!
響應的數據格式:
這個接口的作用,當用戶客訴(充值不到賬)時,讓玩家提供訂單 ID,然后通過這個接口查詢訂單對應的狀態,如果有未消耗的收據(transactionId)時,可以為用戶進行補發或者服務支持。(因為能查到 transactionId,說明玩家這個充值訂單是有效!至於是否消耗,需要服務端來檢查是否有未消耗的收據。)
status=0
,表示有效的訂單號:
{
'status': 0,
'signedTransactions': [
'eyJhbGciOiJFUz.............',
'eyJ0eXAiOiJKV1.............',
'eyJ0eXAiJhbGci.............'
]
}
大家如果有留意,會看到 signedTransactions 是多個 transaction 交易收據,這是為什么呢?其實,這里一個 Order ID
可以會對應多個購買的項目,比如用戶在 1 分鍾里,同時購買了 2 個項目,那些,蘋果在給用戶發送發票時,會合並這2個訂單為一個訂單,此時就只有一個訂單號 Order ID
。
所以,開發者需要注意,Order ID
對於一個購買訂單來說,不是唯一的。驗證用戶的 Order ID
時,也要遍歷完所有的 signedTransactions
,找到可能未消耗的項目。
每個 JWT decode 后,示例格式:
header:
{
"alg":"ES256",
"x5c":[
"MIIEMDC....",
"MIIDFjC....",
"MIICQzC...."
]
}
payload
{
"transactionId": "20000964758895",
"originalTransactionId": "20000964758895",
"bundleId": "com.apple.test",
"productId": "com.apple.iap.60",
"purchaseDate": 1640409900000,
"originalPurchaseDate": 1640409900000,
"quantity": 1,
"type": "Consumable",
"inAppOwnershipType": "PURCHASED",
"signedDate": 1642995907240
}
這是一個消耗型品項的數據格式。
最后,關於解析 JWT 內容,這里先不深入講解,下文在統一講解。
查詢用戶歷史收據(Get Transaction History)
GET https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId}
根據 WWDC21 視頻介紹,接口可以獲取用戶在您的 app 的應用內購買交易歷史記錄。但是在實踐中,發現消耗型項目沒有查到,重新查看接口文檔 Get Transaction History,發現有了新的更新說明:
交易歷史記錄返回結果只支持以下情況:
- 自動續期訂閱
- 非續訂訂閱
- 非消耗型應用內購買項目
- 消耗型應用內購買項目:如果交易被退款、撤銷或 app 尚未完成交易處理等。
響應的數據格式:
需要注意的是,返回的結果中,沒有 status
字段。
{
"revision": "1642993906000_1000000954832195",
"bundleId": "com.apple.test",
"appAppleId": 925021570,
"environment": "Production",
"hasMore": false,
"signedTransactions": [
"eyJhbGciOi...",
"eyJhbGciOi..."
]
}
默認 signedTransactions
返回最多 20 條,目前開發者不能控制這個條數。超過 20 條時,數據有一個字段 hasMore
為 ture,表示有更新的歷史訂單有更新,此時,開發者需要增加請求的查詢字段 revision
,對應的值是從上一次請求返回的數據里對應 revision
字段內容。
舉例來說,請求更多數據:/inApps/v1/history/foriginalTransactionId}&revision=8a170756-e913-42fc-8629-76051f9e1134
。
每個 JWT decode 后,示例格式:
payload
{
"transactionId": "1000000954804912",
"originalTransactionId": "1000000954804912",
"webOrderLineItemId": "1000000071590544",
"bundleId": "com.apple.test",
"productId": "com.apple.iap.month",
"subscriptionGroupIdentifier": "20919269",
"purchaseDate": 1642990548000,
"originalPurchaseDate": 1642990550000,
"expiresDate": 1642990848000,
"quantity": 1,
"type": "Auto-Renewable Subscription",
"inAppOwnershipType": "PURCHASED",
"signedDate": 1643024941850
}
這是一個自動續期訂閱品項的數據格式。
查詢用戶內購退款(Get Refund History)
GET https://api.storekit.itunes.apple.com/inApps/v1/refund/lookup/{originalTransactionId}
通過用戶的任一個購買的 originalTransactionId
可以通過 Get Refund History 查到這個用戶的所有退款記錄訂單。
響應的數據格式:
響應中包含的 signedTransactions
與 App Store Server 通知中一個或多個 REFUND
通知(Notification)中可能是相同。所以,使用此 API 查詢您可能錯過的任何退款通知,例如在服務器停機期間。
但需要注意,僅包括 App Store 批准的退款:消耗性、非消耗型、自動續期訂閱和非續期訂閱。如果用戶沒有收到任何 App Store 批准的退款,成功時返回一個空的 signedTransactions 數組。
{
"signedTransactions": []
}
因為沙盒環境下,操作退款時被蘋果拒絕,線上申請未通過,所以,暫時無返回數據的格式。(后續有退款格式在補充。)
查詢用戶訂閱項目狀態(Get All Subscription Statuses)
GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
訂閱品項狀態查詢 API Get All Subscription Statuses,獲取您 app 中用戶所有訂閱的狀態。
響應的數據格式:
{
"environment": "Sandbox",
"bundleId": "securitynote",
"data": [
{
"subscriptionGroupIdentifier": "20919269",
"lastTransactions": [
{
"status": 2,
"originalTransactionId": "1000000954804912",
"signedTransactionInfo": "eyJhbGciOiJFUz....",
"signedRenewalInfo": "eyJhbGciOiJFUzI1Ni...."
}
]
}
]
}
lastTransactions
是每個訂閱項目的最后的訂閱狀態,status
類型:
- 1:有效
- 2:過期
- 3:賬號扣費重試
- 4:賬號寬限期(這個是開發者設置,比如到期扣費失敗時,可以給用戶延期多長時間。)
- 5:已經撤銷。
signedTransactionInfo 格式示例:
{
"transactionId": "1000000955217725",
"originalTransactionId": "1000000954804912",
"webOrderLineItemId": "1000000071615442",
"bundleId": "com.apple.test",
"productId": "com.apple.iap.month",
"subscriptionGroupIdentifier": "20919269",
"purchaseDate": 1643023487000,
"originalPurchaseDate": 1642990550000,
"expiresDate": 1643023787000,
"quantity": 1,
"type": "Auto-Renewable Subscription",
"inAppOwnershipType": "PURCHASED",
"signedDate": 1643028928116
}
signedRenewalInfo 格式示例:
{
"expirationIntent": 1,
"originalTransactionId": "1000000954804912",
"autoRenewProductId": "com.apple.iap.month",
"productId": "com.apple.iap.month",
"autoRenewStatus": 0,
"isInBillingRetryPeriod": false,
"signedDate": 1643028928116
}
提交防欺詐信息(Send Consumption Information)
PUT https://api.storekit.itunes.apple.com/inApps/v1/transactions/consumption/{originalTransactionId}
這個接口的作用是提交防欺詐信息給蘋果,具體可以查看文檔 Send Consumption Information 。
當用戶申請退款時,蘋果通知(CONSUMPTION_REQUEST
)開發者服務器,開發者可在12小時內,提供用戶的信息(比如游戲金幣是否已消費、用戶充值過多少錢、退款過多少錢等),最后蘋果收到這些信息,協助“退款決策系統” 來決定是否允許用戶退款。詳細可以查看我們之前的 文章內容 了解更多。
用戶提交退款申請,蘋果系統會於 48 小時內在報告問題中更新處理結果。 所以,開發者收到用戶退款通知后,有 12 個小時決定是否要提供防欺詐信息給蘋果。
parameters 字段描述,詳細見文檔:Send Consumption Information
請求的 Response Codes 為 202
就表示蘋果收到了信息。
延長用戶訂閱的時長(Extend a Subscription Renewal Date)
PUT https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/extend/{originalTransactionId}
開發者一年有2次機會給訂閱內購用戶每次加90天免費補償。也就是有自動訂閱類型的 App,可以開發者主動在服務器給用戶補償(免費延長)用戶的訂單時間,每次最多是90天。詳細見文檔 Extend a Subscription Renewal Date。
以下類型的訂閱不符合續訂日期延期的條件:
- 免費優惠期內的訂閱
- 處於賬單重試狀態的非活躍訂閱
- 已經到期,處於寬限期狀態的訂閱
- 在過去365天內已經收到兩次續訂日期延期的訂閱
另外,蘋果有一個提示:當 App Store 計算開發者的佣金比例時,延長期不計入一年的付費服務。
簡單來說,用戶訂閱項目滿一年后,開發者可獲 85% 凈收入。而開發者給用戶免費延長的時間,並不計入這一年的時間里!(懂了吧?)
parameters 字段描述,詳細見文檔:ExtendRenewalDateRequest | Apple Developer Documentation
請求的 Response Codes 為 200
就表示請求成功。
2.4 疑問解答
Authorization: Bearer [signed token]
每當用戶訪問受保護的路由或資源時,用戶可以使用承載(bearer)模式發送 JWT,通常在 Authorization 標頭中,內容格式如下:
Authorization: Bearer [signed token]
隨后,服務器會取出 token 中的內容,來返回對應的內容。需要注意,這個 token 不一定會儲存在 cookie 中,如果存在 cookie 中的話,需要設置為 http-only,防止 XSS。另外,可以看到,如果在如Authorization: Bearer 中發送 token,則跨域資源共享(CORS)將不會成為問題,因為它不使用 cookie。
所以,JWT 的主要目的是在服務端和客戶端之間以安全的方式來轉移聲明。主要的應用場景:
- 認證 Authentication
- 授權 Authorization
- 聯合識別
- 客戶端會話(無狀態的會話)
Error Codes
如果 token 無效或者失效時,返回內容:
Unauthenticated
Request ID: 7F5DBZ7VDX677TOPBAOEUXWSCY.0.0
如果請求的 originalTransactionId
不存在,會報錯 4040005
(OriginalTransactionIdNotFoundError):
{
"errorCode": 4040005,
"errorMessage": "Original transaction id not found."
}
其它的錯誤碼:
Object | errorCode | errorMessage |
---|---|---|
GeneralBadRequestError | 4000000 | Bad request. |
InvalidAppIdentifierError | 4000002 | Invalid request app identifier. |
InvalidRequestRevisionError | 4000005 | Invalid request revision. |
InvalidOriginalTransactionIdError | 4000008 | Invalid original transaction id. |
InvalidExtendByDaysError | 4000009 | Invalid extend by days value. |
InvalidExtendReasonCodeError | 4000010 | Invalid extend reason code. |
InvalidRequestIdentifierError | 4000011 | Invalid request identifier. |
SubscriptionExtensionIneligibleError | 4030004 | Forbidden - subscription state ineligible for extension. |
SubscriptionMaxExtensionError | 4030005 | Forbidden - subscription has reached maximum extension count. |
AccountNotFoundError | 4040001 | Account not found. |
AccountNotFoundRetryableError | 4040002 | Account not found. Please try again. |
AppNotFoundError | 4040003 | App not found. |
AppNotFoundRetryableError | 4040004 | App not found. Please try again. |
OriginalTransactionIdNotFoundError | 4040005 | Original transaction id not found. |
OriginalTransactionIdNotFoundRetryableError | 4040006 | Original transaction id not found. Please try again. |
GeneralInternalError | 5000000 | An unknown error occurred. |
GeneralInternalRetryableError | 5000001 | An unknown error occurred. Please try again. |
詳細的錯誤碼說明,參見文檔:Error Codes。
查詢用戶訂單的收據(Look Up Order ID)
GET https://api.storekit.itunes.apple.com/inApps/v1/lookup/{orderId}
通過這個接口,可以查詢所有訂單嗎?還是只有使用 StoreKit2 創建的訂單才能查詢到?
答:目前筆者找了多筆 2020 年購買的項目訂單號,都能通過 API 查詢到。所以,此接口不限制訂單的購買時期。(至少 2020 年后的可以查到,如有異常,歡迎大家評論區一起交流啊。)
JWT 簽名驗證
向 App Store Server API 發出的每個請求,都需要帶上 JSON Web Token(JWT)令牌來授權。蘋果建議不需要為每個 API 請求生成新令牌。為了從 App Store Server API 獲得更好的性能,請重用已有的簽名令牌,每個令牌有 60 分鍾有效時間。
如果只是想獲取 JWT 的有效負載 Payload 參數,可以直接 base64 Decode Payload 參數就行了,但是如果你需要驗證簽名,則必須使用到 Signture, Header。
可以用 Python 的 PyJWT
庫來 decode:
import jwt
token = "exxxxxx" #需要解碼的 token
data = jwt.decode(token, options={"verify_signature": False})
蘋果的建議是:可以利用各種開源庫,創建和簽署JWT令牌。有關 JWT 更多信息,請參閱 JWT.io。
從 PyJWT 文檔可以看到,JWT 驗證的內容有:
- verify_signature:驗證 JWT 加密簽名
- verify_aud:是否匹配 audience
- verify_iss:是否匹配 issuer
- verify_exp:是否過期
- verify_iat:是否為整數
- verify_nbf:是否為過去的時間(nbf 表示:Not Before 的縮寫,表示 JWT Token 在這個時間之前是無效的。也就是生效時間。)
所以,我們就能明白,驗證 JWT 有那么內容。最重要的是驗證 verify_signature
,當驗證簽名的時候,利用公鑰或者密鑰來解密 Sign,和 base64UrlEncode(header) + "." + base64UrlEncode(payload) 的內容完全一樣的時候,表示驗證通過。
驗證的流程,示例:
import jwt
public_key = "xxxx" #公鑰證書內容
data = jwt.decode(token, key=public_key, algorithms=["ES256"])
那么問題來了,蘋果的公鑰在那里獲取?通過蘋果開發者論壇找到了線索:
簡單來說,JWS 的 x5c 頭字段中包含一個證書鏈(x509),第一個證書包含用於驗證 JWS 簽名的公鑰。從獲取獲取的 signedTransactions
中,取一個 token 解碼的格式如下:
{
"alg": "ES256",
"x5c": [
"MIIEMDCCA7.....",
"MIIDFjCCApy.....",
"MIICQzCCAc......"
]
}
證書可以從蘋果 Apple PKI 頁面下載。
x5c
證書鏈中最后一個證書,對應蘋果的證書 Apple Root CA - G3 Root,但我們需要把 .cer 轉換成 .pem 格式,命令:
openssl x509 -inform der -in AppleRootCA-G3.cer -out AppleRootCA-G3.pem
注解:
X.509:是一種證書標准,主要定義了證書中應該包含哪些內容。其詳情可以參考 RFC5280,SSL 使用的就是這種證書標准。
同樣的 X.509 證書,可能有不同的編碼格式,目前有以下兩種編碼格式:
- DER:Distinguished Encoding Rules,打開看是二進制格式,不可讀.
- PEM:Privacy Enhanced Mail,打開看文本格式,以”—–BEGIN…”開頭,”—–END…”結尾,內容是BASE64編碼。
AppleRootCA-G3.pem
內容,和 x5c
證書鏈中最后一個證書的內容一樣,如下:
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKA==
所以,具體的驗證,參考 Validate StoreKit2 里給出的答案:
def good_signature?(jws_token)
raw = File.read "/Users/steve1/downloads/AppleRootCA-G3.cer"
apple_root_cert = OpenSSL::X509::Certificate.new(raw)
parts = jws_token.split(".")
decoded_parts = parts.map { |part| Base64.decode64(part) }
header = JSON.parse(decoded_parts[0])
cert_chain = header["x5c"].map { |part| OpenSSL::X509::Certificate.new(Base64.decode64(part))}
return false unless cert_chain.last == apple_root_cert
for n in 0..(cert_chain.count - 2)
return false unless cert_chain[n].verify(cert_chain[n+1].public_key)
end
begin
decoded_token = JWT.decode(jws_token, cert_chain[0].public_key, true, { algorithms: ['ES256'] })
!decoded_token.nil?
rescue JWT::JWKError
false
rescue JWT::DecodeError
false
end
end
以上代碼是用 Ruby 寫的,大概實現驗證的邏輯是,用蘋果提供的 AppleRootCA-G3.cer
證書內容驗證 JWT x5c
證書鏈中最后一個證書,然后利用 x509
證書鏈規范,驗證剩下的每個證書鏈,最后用x5c
證書鏈中的第一個證書的公鑰,來驗證 JWT。
Sign in with Apple
除了 App Store Server API,還有 Sign in with Apple、App Store Connet API 等服務,都是使用 JWT 來傳遞。具體的要求和字段可能與 App Store Server API 不相同。比如 Sign in with Apple 的 JWT 不需要 typ
,sub
與 bid
含義一樣,都是表示 Bundle ID,app 的包名。所以這些規范也是一樣,這些細節不一樣時,開發者都需要踩坑然后才能知道,說明了規范的重要性。
{
"alg": "ES256",
"kid": "ABC123DEFG"
}
{
"iss": "DEF123GHIJ",
"iat": 1637179036,
"exp": 1693298100,
"aud": "https://appleid.apple.com",
"sub": "com.apple.test"
}
三、總結
小編開始想把所有訂閱類型都詳細講解,但編寫過程中發現事情很復雜,因為訂閱型項目復雜,有很多字段和作用。限於文章篇幅問題,和蘋果文檔已經有詳細的字段說明,所以本文主要是講解App Store Server API 的整體流程和注意事項。如有錯誤或問題,歡迎大家評論區糾正和交流哈~
其次,App Store Server API 新接口帶來的意義非常重大!以往內購形成產生大量黑灰產,利用蘋果內購的各種環節的漏洞,通過匯率差、僵屍賬號、惡意退款等方式,形成了一條產業鏈的工作室、團伙作案。去年開始,蘋果提供了內購退款通知,今年提供了查詢接口,還有相關的客服接口,雖然都是屬於后期的響應,但在一定程度上,對於打擊黑惡有重要一棒!
最后,從蘋果開放的接口和理念來說,蘋果注重用戶體驗,希望開放者能更好的服務用戶!所以,2022年,希望與大家學習和分享有趣的技術,打磨優秀的產品體驗和服務
!一起努力加油~
37手游 iOS 技術運營團隊全體成員祝:
各位讀者,新年快樂
!虎虎生威
!
歡迎關注我們,了解更多 iOS 和 Apple 的資訊~
四、參考引用
- 蘋果iOS內購三步曲:App內退款、歷史訂單查詢、綁定用戶防掉單!--- WWDC21
- Look Up Order ID | Apple Developer Documentation
- Get Transaction History | Apple Developer Documentation
- Get Refund History | Apple Developer Documentation
- Get All Subscription Statuses | Apple Developer Documentation
- Send Consumption Information | Apple Developer Documentation
- Extend a Subscription Renewal Date | Apple Developer Documentation
- Generating Tokens for API Requests | Apple Developer Documentation
- Generate and Validate Tokens | Apple Developer Documentation
- RFC 7519 - JSON Web Token (JWT)
- 冷飯新炒:理解JWT的實現原理和基本使用 - Throwable
- Validating "Sign in with Apple" Authorization Code - Parikshit Agnihotry
- What is a JWS and how to encode it for Apple In-App Purchases?
- Fraud in In-App Subscriptions : how to crack down on fraud from malicious users
- JWT(JSON Web)使用_wichandy的技術博客_51CTO博客_jwt使用教程
- RFC 7519 - JSON Web Token (JWT)
- 自動續期訂閱 - App Store - Apple Developer
- Getting only decoded payload from JWT in python - Stack Overflow
- Validate StoreKit2 in-app purchase jwsRepresentation in backend| Apple Developer Forums
- How do I convert a .cer certificate to .pem? - Server Fault