Skip to content

Commit

Permalink
Add endpoint to verify IAP purchases via transaction ID
Browse files Browse the repository at this point in the history
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: damus-io/damus#2036

Signed-off-by: Daniel D’Aquino <[email protected]>
Signed-off-by: William Casarin <[email protected]>
  • Loading branch information
danieldaquino authored and jb55 committed Mar 11, 2024
1 parent 390a05c commit 8e7f076
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 8 deletions.
32 changes: 31 additions & 1 deletion src/app_store_receipt_verifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number|null>} 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.
Expand Down Expand Up @@ -202,5 +232,5 @@ function getAppStoreEnvironmentFromEnv() {
}

module.exports = {
verify_receipt
verify_receipt, verify_transaction_id
};
60 changes: 59 additions & 1 deletion src/router_config.js
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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": <TRANSACTION_ID_AS_UINT64>
// "account_uuid": <UUID_OF_ACCOUNT>
// }
//
// 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
Expand Down
20 changes: 17 additions & 3 deletions test/controllers/mock_iap_controller.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion test/controllers/purple_test_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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<Object>} The response from the server.
Expand Down
41 changes: 39 additions & 2 deletions test/iap_flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down

0 comments on commit 8e7f076

Please sign in to comment.