lockbook-server 26.4.13

Lockbook's server, exposed as a library for testing.
Documentation
use crate::billing::app_store_model::{
    ErrorBody, LastTransactionItem, SubGroupIdentifierItem, SubsStatusesResponse, TransactionInfo,
};
use crate::config::AppleConfig;
use crate::{ClientError, ServerError};
use async_trait::async_trait;
use itertools::Itertools;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use lb_rs::model::api::UpgradeAccountAppStoreError;
use lb_rs::model::clock::get_time;
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
use tracing::{debug, error};

pub const SUB_STATUS_PROD: &str = "https://api.storekit.itunes.apple.com/inApps/v1/subscriptions";
pub const SUB_STATUS_SANDBOX: &str =
    "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions";

const ALG: &str = "ES256";
const TYP: &str = "JWT";
const AUDIENCE: &str = "appstoreconnect-v1";
const BUNDLE_ID: &str = "app.lockbook";

pub const ORIGINAL_TRANS_ID_NOT_FOUND_ERR_CODE: u64 = 4040005;
pub const TRANS_ID_NOT_FOUND_ERR_CODE: u64 = 4040010;

#[derive(Serialize, Deserialize, Debug)]
pub struct Claims {
    iss: String,
    iat: usize,
    exp: usize,
    aud: String,
    bid: String,
}

pub fn gen_auth_req(
    config: &AppleConfig, request: RequestBuilder,
) -> Result<RequestBuilder, ServerError<UpgradeAccountAppStoreError>> {
    let mut header = Header::new(Algorithm::ES256);
    header.kid = Some(config.iap_key_id.clone());

    let iat = (get_time().0 / 1000) as usize;
    let exp = (get_time().0 / 1000 + 1200) as usize;

    let claims = Claims {
        iss: config.issuer_id.to_string(),
        iat,
        exp,
        aud: AUDIENCE.to_string(),
        bid: BUNDLE_ID.to_string(),
    };

    let token = encode(&header, &claims, &EncodingKey::from_ec_pem(config.iap_key.as_bytes())?)?;
    Ok(request
        .header("alg", ALG)
        .header("kid", &config.iap_key_id)
        .header("typ", TYP)
        .bearer_auth(token))
}

#[async_trait]
pub trait AppStoreClient: Sync + Send + Clone + 'static {
    async fn get_sub_status(
        &self, config: &AppleConfig, original_transaction_id: &str,
    ) -> Result<(LastTransactionItem, TransactionInfo), ServerError<UpgradeAccountAppStoreError>>;
}

#[async_trait]
impl AppStoreClient for Client {
    async fn get_sub_status(
        &self, config: &AppleConfig, original_transaction_id: &str,
    ) -> Result<(LastTransactionItem, TransactionInfo), ServerError<UpgradeAccountAppStoreError>>
    {
        let resp =
            gen_auth_req(config, self.get(format!("{SUB_STATUS_PROD}/{original_transaction_id}")))?
                .send()
                .await?;

        let resp_status = resp.status().as_u16();
        match resp_status {
            200 => {
                debug!("Successfully retrieved subscription status from production apple url");

                let sub_status: SubsStatusesResponse = resp.json().await?;

                for sub_group in &sub_status.data {
                    if sub_group.sub_group == config.monthly_sub_group_id {
                        return get_trans(&sub_status, sub_group);
                    }
                }

                Err(internal!(
                    "No usable data returned from apple's production subscriptions statuses endpoint despite assumed match. resp_body: {:?}, monthly_sub_group: {}",
                    sub_status,
                    config.monthly_sub_group_id
                ))
            }
            400 | 404 => {
                let error: ErrorBody = resp.json().await?;

                if error.error_code == ORIGINAL_TRANS_ID_NOT_FOUND_ERR_CODE
                    || error.error_code == TRANS_ID_NOT_FOUND_ERR_CODE
                {
                    debug!(
                        "Could not verify subscription from apple's production servers, trying sandbox"
                    );

                    let resp = gen_auth_req(
                        config,
                        self.get(format!("{SUB_STATUS_SANDBOX}/{original_transaction_id}")),
                    )?
                    .send()
                    .await?;

                    let resp_status = resp.status().as_u16();
                    match resp_status {
                        200 => {
                            debug!(
                                "Successfully retrieved subscription status from sandbox apple url"
                            );

                            let sub_status: SubsStatusesResponse = resp.json().await?;

                            for sub_group in &sub_status.data {
                                if sub_group.sub_group == config.monthly_sub_group_id {
                                    return get_trans(&sub_status, sub_group);
                                }
                            }

                            return Err(internal!(
                                "No usable data returned from apple's sandbox subscriptions statuses endpoint despite assumed match. resp_body: {:?}, monthly_sub_group: {}",
                                sub_status,
                                config.monthly_sub_group_id
                            ));
                        }
                        400 | 404 => {
                            error!(
                                ?resp_status,
                                ?error,
                                ?original_transaction_id,
                                "Failed to verify possible sandbox subscription"
                            );

                            return Err(ClientError(
                                UpgradeAccountAppStoreError::InvalidAuthDetails,
                            ));
                        }
                        _ => return Err(internal!("Unexpected response: {:?}", resp_status)),
                    }
                }

                error!(
                    ?resp_status,
                    ?error,
                    ?original_transaction_id,
                    "Failed to verify possible production subscription"
                );

                Err(ClientError(UpgradeAccountAppStoreError::InvalidAuthDetails))
            }
            _ => Err(internal!("Unexpected response: {:?}", resp_status)),
        }
    }
}

fn get_trans(
    sub_status: &SubsStatusesResponse, sub_group: &SubGroupIdentifierItem,
) -> Result<(LastTransactionItem, TransactionInfo), ServerError<UpgradeAccountAppStoreError>> {
    let last_trans = sub_group
        .last_transactions
        .first()
        .ok_or(ClientError(UpgradeAccountAppStoreError::InvalidAuthDetails))?;

    let part = <&str>::clone(
        last_trans
            .signed_transaction_info
            .split('.')
            .collect_vec()
            .get(1)
            .ok_or_else::<ServerError<UpgradeAccountAppStoreError>, _>(|| {
                internal!("There should be a payload in apple jwt: {:?}", sub_status)
            })?,
    );

    let trans_info = serde_json::from_slice(&base64::decode(part).map_err::<ServerError<
        UpgradeAccountAppStoreError,
    >, _>(|err| {
        internal!("Cannot decode apple jwt payload: {:?}, err: {:?}", part, err)
    })?)?;

    Ok((last_trans.clone(), trans_info))
}