Skip to main content

lockbook_server_lib/billing/
app_store_client.rs

1use crate::billing::app_store_model::{
2    ErrorBody, LastTransactionItem, SubGroupIdentifierItem, SubsStatusesResponse, TransactionInfo,
3};
4use crate::config::AppleConfig;
5use crate::{ClientError, ServerError};
6use async_trait::async_trait;
7use itertools::Itertools;
8use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
9use lb_rs::model::api::UpgradeAccountAppStoreError;
10use lb_rs::model::clock::get_time;
11use reqwest::{Client, RequestBuilder};
12use serde::{Deserialize, Serialize};
13use tracing::{debug, error};
14
15pub const SUB_STATUS_PROD: &str = "https://api.storekit.itunes.apple.com/inApps/v1/subscriptions";
16pub const SUB_STATUS_SANDBOX: &str =
17    "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions";
18
19const ALG: &str = "ES256";
20const TYP: &str = "JWT";
21const AUDIENCE: &str = "appstoreconnect-v1";
22const BUNDLE_ID: &str = "app.lockbook";
23
24pub const ORIGINAL_TRANS_ID_NOT_FOUND_ERR_CODE: u64 = 4040005;
25pub const TRANS_ID_NOT_FOUND_ERR_CODE: u64 = 4040010;
26
27#[derive(Serialize, Deserialize, Debug)]
28pub struct Claims {
29    iss: String,
30    iat: usize,
31    exp: usize,
32    aud: String,
33    bid: String,
34}
35
36pub fn gen_auth_req(
37    config: &AppleConfig, request: RequestBuilder,
38) -> Result<RequestBuilder, ServerError<UpgradeAccountAppStoreError>> {
39    let mut header = Header::new(Algorithm::ES256);
40    header.kid = Some(config.iap_key_id.clone());
41
42    let iat = (get_time().0 / 1000) as usize;
43    let exp = (get_time().0 / 1000 + 1200) as usize;
44
45    let claims = Claims {
46        iss: config.issuer_id.to_string(),
47        iat,
48        exp,
49        aud: AUDIENCE.to_string(),
50        bid: BUNDLE_ID.to_string(),
51    };
52
53    let token = encode(&header, &claims, &EncodingKey::from_ec_pem(config.iap_key.as_bytes())?)?;
54    Ok(request
55        .header("alg", ALG)
56        .header("kid", &config.iap_key_id)
57        .header("typ", TYP)
58        .bearer_auth(token))
59}
60
61#[async_trait]
62pub trait AppStoreClient: Sync + Send + Clone + 'static {
63    async fn get_sub_status(
64        &self, config: &AppleConfig, original_transaction_id: &str,
65    ) -> Result<(LastTransactionItem, TransactionInfo), ServerError<UpgradeAccountAppStoreError>>;
66}
67
68#[async_trait]
69impl AppStoreClient for Client {
70    async fn get_sub_status(
71        &self, config: &AppleConfig, original_transaction_id: &str,
72    ) -> Result<(LastTransactionItem, TransactionInfo), ServerError<UpgradeAccountAppStoreError>>
73    {
74        let resp =
75            gen_auth_req(config, self.get(format!("{SUB_STATUS_PROD}/{original_transaction_id}")))?
76                .send()
77                .await?;
78
79        let resp_status = resp.status().as_u16();
80        match resp_status {
81            200 => {
82                debug!("Successfully retrieved subscription status from production apple url");
83
84                let sub_status: SubsStatusesResponse = resp.json().await?;
85
86                for sub_group in &sub_status.data {
87                    if sub_group.sub_group == config.monthly_sub_group_id {
88                        return get_trans(&sub_status, sub_group);
89                    }
90                }
91
92                Err(internal!(
93                    "No usable data returned from apple's production subscriptions statuses endpoint despite assumed match. resp_body: {:?}, monthly_sub_group: {}",
94                    sub_status,
95                    config.monthly_sub_group_id
96                ))
97            }
98            400 | 404 => {
99                let error: ErrorBody = resp.json().await?;
100
101                if error.error_code == ORIGINAL_TRANS_ID_NOT_FOUND_ERR_CODE
102                    || error.error_code == TRANS_ID_NOT_FOUND_ERR_CODE
103                {
104                    debug!(
105                        "Could not verify subscription from apple's production servers, trying sandbox"
106                    );
107
108                    let resp = gen_auth_req(
109                        config,
110                        self.get(format!("{SUB_STATUS_SANDBOX}/{original_transaction_id}")),
111                    )?
112                    .send()
113                    .await?;
114
115                    let resp_status = resp.status().as_u16();
116                    match resp_status {
117                        200 => {
118                            debug!(
119                                "Successfully retrieved subscription status from sandbox apple url"
120                            );
121
122                            let sub_status: SubsStatusesResponse = resp.json().await?;
123
124                            for sub_group in &sub_status.data {
125                                if sub_group.sub_group == config.monthly_sub_group_id {
126                                    return get_trans(&sub_status, sub_group);
127                                }
128                            }
129
130                            return Err(internal!(
131                                "No usable data returned from apple's sandbox subscriptions statuses endpoint despite assumed match. resp_body: {:?}, monthly_sub_group: {}",
132                                sub_status,
133                                config.monthly_sub_group_id
134                            ));
135                        }
136                        400 | 404 => {
137                            error!(
138                                ?resp_status,
139                                ?error,
140                                ?original_transaction_id,
141                                "Failed to verify possible sandbox subscription"
142                            );
143
144                            return Err(ClientError(
145                                UpgradeAccountAppStoreError::InvalidAuthDetails,
146                            ));
147                        }
148                        _ => return Err(internal!("Unexpected response: {:?}", resp_status)),
149                    }
150                }
151
152                error!(
153                    ?resp_status,
154                    ?error,
155                    ?original_transaction_id,
156                    "Failed to verify possible production subscription"
157                );
158
159                Err(ClientError(UpgradeAccountAppStoreError::InvalidAuthDetails))
160            }
161            _ => Err(internal!("Unexpected response: {:?}", resp_status)),
162        }
163    }
164}
165
166fn get_trans(
167    sub_status: &SubsStatusesResponse, sub_group: &SubGroupIdentifierItem,
168) -> Result<(LastTransactionItem, TransactionInfo), ServerError<UpgradeAccountAppStoreError>> {
169    let last_trans = sub_group
170        .last_transactions
171        .first()
172        .ok_or(ClientError(UpgradeAccountAppStoreError::InvalidAuthDetails))?;
173
174    let part = <&str>::clone(
175        last_trans
176            .signed_transaction_info
177            .split('.')
178            .collect_vec()
179            .get(1)
180            .ok_or_else::<ServerError<UpgradeAccountAppStoreError>, _>(|| {
181                internal!("There should be a payload in apple jwt: {:?}", sub_status)
182            })?,
183    );
184
185    let trans_info = serde_json::from_slice(&base64::decode(part).map_err::<ServerError<
186        UpgradeAccountAppStoreError,
187    >, _>(|err| {
188        internal!("Cannot decode apple jwt payload: {:?}, err: {:?}", part, err)
189    })?)?;
190
191    Ok((last_trans.clone(), trans_info))
192}