diff --git a/src/app_store_receipt_verifier.js b/src/app_store_receipt_verifier.js index fddcbfc..eabfc0f 100644 --- a/src/app_store_receipt_verifier.js +++ b/src/app_store_receipt_verifier.js @@ -12,6 +12,20 @@ const { current_time } = require("./utils") const fs = require('fs') const debug = require('debug')('api:iap') +const DEFAULT_ROOT_CA_DIR = './apple-root-ca' +/** + * Mock transaction history for testing purposes. + * @type {Transaction[]} + */ +const MOCK_TRANSACTION_HISTORY = [{ + type: "iap", + id: "1", + start_date: current_time(), + end_date: current_time() + 60 * 60 * 24 * 30, + purchased_date: current_time(), + duration: null +}]; + /** * Verifies the receipt data and returns the expiry date if the receipt is valid. * @@ -25,18 +39,11 @@ async function verify_receipt(receipt_data, authenticated_account_token) { // Mocking logic for testing purposes if (process.env.MOCK_VERIFY_RECEIPT == "true") { debug("Mocking verify_receipt with expiry date 30 days from now"); - return [{ - type: "iap", - id: "1", - start_date: current_time(), - end_date: current_time() + 60 * 60 * 24 * 30, - purchased_date: current_time(), - duration: null - }] + return MOCK_TRANSACTION_HISTORY; } // Setup the environment and client - const rootCaDir = process.env.IAP_ROOT_CA_DIR || './apple-root-ca' + const rootCaDir = process.env.IAP_ROOT_CA_DIR || DEFAULT_ROOT_CA_DIR; const bundleId = process.env.IAP_BUNDLE_ID; const environment = getAppStoreEnvironmentFromEnv(); const client = createAppStoreServerAPIClientFromEnv(); @@ -65,18 +72,11 @@ async function verify_transaction_id(transaction_id, authenticated_account_token // Mocking logic for testing purposes if (process.env.MOCK_VERIFY_RECEIPT == "true") { debug("Mocking verify_receipt with expiry date 30 days from now"); - return [{ - type: "iap", - id: "1", - start_date: current_time(), - end_date: current_time() + 60 * 60 * 24 * 30, - purchased_date: current_time(), - duration: null - }]; + return MOCK_TRANSACTION_HISTORY; } // Setup the environment and client - const rootCaDir = process.env.IAP_ROOT_CA_DIR || './apple-root-ca' + const rootCaDir = process.env.IAP_ROOT_CA_DIR || DEFAULT_ROOT_CA_DIR; const bundleId = process.env.IAP_BUNDLE_ID; const environment = getAppStoreEnvironmentFromEnv(); const client = createAppStoreServerAPIClientFromEnv(); @@ -89,6 +89,35 @@ async function verify_transaction_id(transaction_id, authenticated_account_token } +/** + * Looks up the order id and returns the verified transaction history if valid + * + * @param {string} order_id - The order id to lookup + * + * @returns {Promise} The validated transactions + */ +async function lookup_order_id(order_id) { + debug("Looking up order id '%d'", order_id); + // Mocking logic for testing purposes + if (process.env.MOCK_VERIFY_RECEIPT == "true") { + debug("Mocking verify_receipt with expiry date 30 days from now"); + return MOCK_TRANSACTION_HISTORY; + } + + // Setup the environment and client + const rootCaDir = process.env.IAP_ROOT_CA_DIR || DEFAULT_ROOT_CA_DIR; + const bundleId = process.env.IAP_BUNDLE_ID; + const environment = getAppStoreEnvironmentFromEnv(); + const client = createAppStoreServerAPIClientFromEnv(); + + // If the transaction ID is present, fetch the transaction history, verify the transactions, and return the latest expiry date + if (order_id != null) { + return await fetchValidatedTransactionsFromOrderId(client, order_id, rootCaDir, environment, bundleId, undefined); + } + return Promise.resolve(null); +} + + /** * Fetches transaction history with the App Store API, verifies the transactions, and returns formatted transactions. * It also verifies if the transaction belongs to the account who made the request. @@ -114,17 +143,59 @@ async function fetchValidatedTransactions(client, transactionId, rootCaDir, envi return null; } return validDecodedTransactions.map((decodedTransaction) => { - return { - type: "iap", - id: decodedTransaction.transactionId, - start_date: decodedTransaction.purchaseDate / 1000, - end_date: decodedTransaction.expiresDate / 1000, - purchased_date: decodedTransaction.purchaseDate / 1000, - duration: null - } + return transformDecodedTransactionToTransaction(decodedTransaction) }) } +/** + * Fetches transaction history associated with an Order ID with the App Store API, verifies the transactions, and returns formatted transactions. + * An Order ID is a unique identifier for a transaction that is generated by the App Store and is included in receipt emails, so it is useful for customer support. + * + * @param {AppStoreServerAPIClient} client - The App Store API client. + * @param {string} orderId - The order ID to fetch history for. + * @param {string} rootCaDir - The directory containing Apple root CA certificates for verification. + * @param {Environment} environment - The App Store environment. + * @param {string} bundleId - The bundle ID of the app. + * @param {string | undefined} authenticatedAccountToken - The UUID account token of the user who is authenticated in this request. If undefined, UUID authentication will be skipped. + * + * @returns {Promise} The validated transactions +*/ +async function fetchValidatedTransactionsFromOrderId(client, orderId, rootCaDir, environment, bundleId, authenticatedAccountToken) { + const transactions = await client.lookUpOrderId(orderId); + debug("[Order ID: %s] Fetched transaction history; Found %d transactions", orderId, transactions.signedTransactions); + const rootCAs = readCertificateFiles(rootCaDir); + const decodedTransactions = await verifyAndDecodeTransactions(transactions.signedTransactions, rootCAs, environment, bundleId); + debug("[Order ID: %s] Verified and decoded %d transactions", orderId, decodedTransactions.length); + const validDecodedTransactions = authenticatedAccountToken == undefined ? decodedTransactions : filterTransactionsThatBelongToAccount(decodedTransactions, authenticatedAccountToken); + if (authenticatedAccountToken != undefined) { + debug("[Account token: %s] Filtered transactions that belong to the account UUID. Found %d matching transactions", authenticatedAccountToken, validDecodedTransactions.length); + } + if (validDecodedTransactions.length === 0) { + return null; + } + return validDecodedTransactions.map((decodedTransaction) => { + return transformDecodedTransactionToTransaction(decodedTransaction) + }) +} + +/** + * Transforms a JWSTransactionDecodedPayload into a Transaction object + * + * @param {JWSTransactionDecodedPayload} decodedTransaction - The decoded transaction to transform. + * @returns {Transaction} The transformed transaction. + * + */ +function transformDecodedTransactionToTransaction(decodedTransaction) { + return { + type: "iap", + id: decodedTransaction.transactionId, + start_date: decodedTransaction.purchaseDate / 1000, + end_date: decodedTransaction.expiresDate / 1000, + purchased_date: decodedTransaction.purchaseDate / 1000, + duration: null + } +} + /** * Filters out transactions that do not belong to the authorized account token. * @@ -168,7 +239,7 @@ function readCertificateFiles(directory) { * @param {Buffer[]} rootCAs - Apple root CA certificate contents for verification. * @param {Environment} environment - The App Store environment. * @param {string} bundleId - The bundle ID of the app. - * @returns {Promise} The decoded transactions. + * @returns {Promise} The decoded transactions. */ async function verifyAndDecodeTransactions(transactions, rootCAs, environment, bundleId) { const verifier = new SignedDataVerifier(rootCAs, true, environment, bundleId); @@ -254,5 +325,5 @@ function getAppStoreEnvironmentFromEnv() { } module.exports = { - verify_receipt, verify_transaction_id + verify_receipt, verify_transaction_id, lookup_order_id };