lockbook_server_lib/billing/
app_store_client.rs1use 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}