From 8e7f076861e2cb8641b22b1e49a30505dc98abdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Fri, 8 Mar 2024 21:13:54 +0000 Subject: [PATCH] Add endpoint to verify IAP purchases via transaction ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was observed that sending the receipt to the server right after performing the StoreKit purchase caused issues due to the receipt not being completely ready when it got sent, causing rejections by the server. This commit, in conjuction with iOS changes, implements purchase verification through transaction IDs directly. This also adds automated test coverage to IAP transaction ID verification endpoint Testing: All automated tests passing. Manual test performed and reported on the iOS side. Part of: https://github.com/damus-io/damus/issues/2036 Signed-off-by: Daniel D’Aquino Signed-off-by: William Casarin --- src/app_store_receipt_verifier.js | 32 ++++++++++++- src/router_config.js | 60 ++++++++++++++++++++++++- test/controllers/mock_iap_controller.js | 20 +++++++-- test/controllers/purple_test_client.js | 18 +++++++- test/iap_flow.test.js | 41 ++++++++++++++++- 5 files changed, 163 insertions(+), 8 deletions(-) diff --git a/src/app_store_receipt_verifier.js b/src/app_store_receipt_verifier.js index fd64057..11468d2 100644 --- a/src/app_store_receipt_verifier.js +++ b/src/app_store_receipt_verifier.js @@ -42,6 +42,36 @@ async function verify_receipt(receipt_data, authenticated_account_token) { return Promise.resolve(null); } +/** + * Verifies the transaction id and returns the expiry date if the transaction is valid. + * + * @param {number} transaction_id - The transaction id to verify. + * @param {string} authenticated_account_token - The UUID account token of the user who is authenticated in this request. + * + * @returns {Promise} The expiry date of the receipt if valid, null otherwise. + */ +async function verify_transaction_id(transaction_id, authenticated_account_token) { + debug("Verifying transaction id '%d' with authenticated account token: %s", 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 current_time() + 60 * 60 * 24 * 30; + } + + // Setup the environment and client + const rootCaDir = process.env.IAP_ROOT_CA_DIR || './apple-root-ca' + 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 (transaction_id != null) { + return await fetchLastVerifiedExpiryDate(client, transaction_id, rootCaDir, environment, bundleId, authenticated_account_token); + } + return Promise.resolve(null); +} + + /** * Fetches transaction history with the App Store API, verifies the transactions, and returns the last valid expiry date. * It also verifies if the transaction belongs to the account who made the request. @@ -202,5 +232,5 @@ function getAppStoreEnvironmentFromEnv() { } module.exports = { - verify_receipt + verify_receipt, verify_transaction_id }; diff --git a/src/router_config.js b/src/router_config.js index 62b2e0c..5a60a3d 100644 --- a/src/router_config.js +++ b/src/router_config.js @@ -1,7 +1,7 @@ const { json_response, simple_response, error_response, invalid_request, unauthorized_response } = require('./server_helpers') const { create_account, get_account_info_payload, check_account, get_account, put_account, get_account_and_user_id, get_user_uuid, bump_iap_set_expiry, delete_account } = require('./user_management') const handle_translate = require('./translate') -const verify_receipt = require('./app_store_receipt_verifier').verify_receipt +const { verify_receipt, verify_transaction_id } = require('./app_store_receipt_verifier'); const bodyParser = require('body-parser') const cors = require('cors'); const { required_nip98_auth, capture_raw_body, optional_nip98_auth } = require('./nip98_auth') @@ -127,6 +127,64 @@ function config_router(app) { json_response(res, get_account_info_payload(user_id, account)) return }) + + + // This route is used to verify a transaction id and create (or extend the expiry date of) an account + // This is used for Apple in-app purchases + // + // Payload should be a JSON object in the following format: + // { + // "transaction_id": + // "account_uuid": + // } + // + // Make sure to set the Content-Type header to application/json + router.post('/accounts/:pubkey/apple-iap/transaction-id', required_nip98_auth, async (req, res) => { + const pubkey = req.params.pubkey + if (!pubkey) { + invalid_request(res, 'Could not parse account pubkey') + return + } + + if (pubkey != req.authorized_pubkey) { + unauthorized_response(res, 'You are not authorized to access this account') + return + } + + const transaction_id = req.body.transaction_id + if (!transaction_id) { + invalid_request(res, 'Missing transaction_id') + return + } + + const alleged_account_uuid = req.body.account_uuid + if (!alleged_account_uuid) { + invalid_request(res, 'Missing account_uuid') + return + } + + const account_uuid = get_user_uuid(app, pubkey) + if (account_uuid.toUpperCase() != alleged_account_uuid.toUpperCase()) { + unauthorized_response(res, 'The account UUID is not valid for this account. Expected: "' + account_uuid + '", got: "' + alleged_account_uuid + '"') + return + } + + let expiry_date = await verify_transaction_id(transaction_id, account_uuid) + if (!expiry_date) { + unauthorized_response(res, 'Transaction ID invalid') + return + } + + const { account: new_account, request_error } = bump_iap_set_expiry(app, req.authorized_pubkey, expiry_date) + if (request_error) { + error_response(res, request_error) + return + } + + let { account, user_id } = get_account_and_user_id(app, req.authorized_pubkey) + json_response(res, get_account_info_payload(user_id, account)) + return + }) } // MARK: Product and checkout routes diff --git a/test/controllers/mock_iap_controller.js b/test/controllers/mock_iap_controller.js index a3ff9ec..b03c128 100644 --- a/test/controllers/mock_iap_controller.js +++ b/test/controllers/mock_iap_controller.js @@ -36,10 +36,20 @@ class MockIAPController { get_iap_receipt_data(user_uuid) { return MOCK_RECEIPT_DATA[user_uuid] } - - + + + /** + * Gets the transaction ID for a given user UUID + * + * @param {string} user_uuid - The UUID of the user to get the transaction ID for + * @returns {number} - The transaction ID + */ + get_transaction_id(user_uuid) { + return MOCK_TRANSACTION_IDS[user_uuid] + } + // MARK: - Mocking AppStoreServerAPIClient - + /** * Generate a mock class for AppStoreServerAPIClient * @@ -94,6 +104,10 @@ const MOCK_IAP_DATES = { } } +const MOCK_TRANSACTION_IDS = { + [MOCK_ACCOUNT_UUIDS[0]]: 2000000529341175, +} + const MOCK_TRANSACTION_HISTORY_DATA = { // Belongs to user 0 '2000000529341175': [ diff --git a/test/controllers/purple_test_client.js b/test/controllers/purple_test_client.js index 7f897ec..5022519 100644 --- a/test/controllers/purple_test_client.js +++ b/test/controllers/purple_test_client.js @@ -117,9 +117,25 @@ class PurpleTestClient { }, options) } + /** + * Sends an IAP (Apple In-app purchase) transaction ID to the server. + * + * @param {string} user_uuid - The UUID of the user + * @param {number} transaction_id - The transaction ID + * @param {PurpleTestClientRequestOptions} options - The request options + * @returns {Promise} The response from the server + */ + async send_transaction_id(user_uuid, transaction_id, options = {}) { + options = PurpleTestClient.patch_options({ nip98_authenticated: true, content_type: 'application/json' }, options) + return await this.post(`/accounts/${this.public_key}/apple-iap/transaction-id`, { + account_uuid: user_uuid, + transaction_id: transaction_id + }, options) + } + /** * Sends a GET request to the server. - * + * * @param {string} path - The path to send the request to. * @param {PurpleTestClientRequestOptions} options - The request options. * @returns {Promise} The response from the server. diff --git a/test/iap_flow.test.js b/test/iap_flow.test.js index 521db71..331aca4 100644 --- a/test/iap_flow.test.js +++ b/test/iap_flow.test.js @@ -6,10 +6,10 @@ const { PurpleTestController } = require('./controllers/purple_test_controller.j const { PURPLE_ONE_MONTH } = require('../src/invoicing.js'); const { MOCK_ACCOUNT_UUIDS, MOCK_IAP_DATES, MOCK_RECEIPT_DATA } = require('./controllers/mock_iap_controller.js'); -test('IAP Flow — Expected flow', async (t) => { +test('IAP Flow — Expected flow (receipts)', async (t) => { // Initialize the PurpleTestController const purple_api_controller = await PurpleTestController.new(t); - + const user_uuid = MOCK_ACCOUNT_UUIDS[0] purple_api_controller.set_current_time(MOCK_IAP_DATES[user_uuid].purchase_date); @@ -41,6 +41,43 @@ test('IAP Flow — Expected flow', async (t) => { t.end(); }); + +test('IAP Flow — Expected flow (transaction ID)', async (t) => { + // Initialize the PurpleTestController + const purple_api_controller = await PurpleTestController.new(t); + + const user_uuid = MOCK_ACCOUNT_UUIDS[0] + purple_api_controller.set_current_time(MOCK_IAP_DATES[user_uuid].purchase_date); + + // Instantiate a new client + const user_pubkey_1 = purple_api_controller.new_client(); + + // Try to get the account info + const response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(response.statusCode, 404); + + // Simulate IAP purchase on the iOS side + + purple_api_controller.set_account_uuid(user_pubkey_1, user_uuid); // Associate the pubkey with the user_uuid on the server + const transaction_id = purple_api_controller.iap.get_transaction_id(user_uuid); // Get the transaction ID from the iOS side + + // Send the receipt to the server to activate the account + const iap_response = await purple_api_controller.clients[user_pubkey_1].send_transaction_id(user_uuid, transaction_id); + t.same(iap_response.statusCode, 200); + + // Read the account info now + const account_info_response = await purple_api_controller.clients[user_pubkey_1].get_account(); + t.same(account_info_response.statusCode, 200); + t.same(account_info_response.body.pubkey, user_pubkey_1) + t.same(account_info_response.body.created_at, purple_api_controller.current_time()); + t.same(account_info_response.body.expiry, MOCK_IAP_DATES[user_uuid].expiry_date); + t.same(account_info_response.body.subscriber_number, 1); + t.same(account_info_response.body.active, true); + + t.end(); +}); + + test('IAP Flow — Invalid receipt should not be authorized or crash the server', async (t) => { // Initialize the PurpleTestController const purple_api_controller = await PurpleTestController.new(t);