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);