From 2f70a6a16a050da289d3b23236d0752784b73ded Mon Sep 17 00:00:00 2001 From: tikhop Date: Wed, 3 Apr 2024 13:08:06 +0200 Subject: [PATCH 1/4] feat: Support App Store Server API v1.10.1 --- src/primitives/jws_transaction_decoded_payload.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primitives/jws_transaction_decoded_payload.rs b/src/primitives/jws_transaction_decoded_payload.rs index 48836ea..be4c855 100644 --- a/src/primitives/jws_transaction_decoded_payload.rs +++ b/src/primitives/jws_transaction_decoded_payload.rs @@ -162,10 +162,10 @@ pub struct JWSTransactionDecodedPayload { /// [currency](https://developer.apple.com/documentation/appstoreserverapi/currency) pub currency: Option, - /// The price of the in-app purchase or subscription offer that you configured in App Store Connect, as an integer. + /// The price, in milliunits, of the in-app purchase or subscription offer that you configured in App Store Connect. /// /// [price](https://developer.apple.com/documentation/appstoreserverapi/price) - pub price: Option, + pub price: Option, /// The payment mode you configure for an introductory offer, promotional offer, or offer code on an auto-renewable subscription. /// From a3472fa5644d473f4b852050d845be2d424a7b78 Mon Sep 17 00:00:00 2001 From: tikhop Date: Wed, 3 Apr 2024 15:43:58 +0200 Subject: [PATCH 2/4] feat: Support App Store Server Notifications v2.10 --- ...gnedExternalPurchaseTokenNotification.json | 13 ++ ...ernalPurchaseTokenSandboxNotification.json | 13 ++ src/primitives/external_purchase_token.rs | 37 ++++++ src/primitives/mod.rs | 1 + src/primitives/notification_type_v2.rs | 2 + .../response_body_v2_decoded_payload.rs | 12 +- src/primitives/subtype.rs | 2 + src/signed_data_verifier.rs | 123 ++++++++++++++++-- 8 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 assets/signedExternalPurchaseTokenNotification.json create mode 100644 assets/signedExternalPurchaseTokenSandboxNotification.json create mode 100644 src/primitives/external_purchase_token.rs diff --git a/assets/signedExternalPurchaseTokenNotification.json b/assets/signedExternalPurchaseTokenNotification.json new file mode 100644 index 0000000..ba71f43 --- /dev/null +++ b/assets/signedExternalPurchaseTokenNotification.json @@ -0,0 +1,13 @@ +{ + "notificationType": "EXTERNAL_PURCHASE_TOKEN", + "subtype": "UNREPORTED", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "version": "2.0", + "signedDate": 1698148900000, + "externalPurchaseToken": { + "externalPurchaseId": "b2158121-7af9-49d4-9561-1f588205523e", + "tokenCreationDate": 1698148950000, + "appAppleId": 55555, + "bundleId": "com.example" + } +} \ No newline at end of file diff --git a/assets/signedExternalPurchaseTokenSandboxNotification.json b/assets/signedExternalPurchaseTokenSandboxNotification.json new file mode 100644 index 0000000..ec99fcf --- /dev/null +++ b/assets/signedExternalPurchaseTokenSandboxNotification.json @@ -0,0 +1,13 @@ +{ + "notificationType": "EXTERNAL_PURCHASE_TOKEN", + "subtype": "UNREPORTED", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "version": "2.0", + "signedDate": 1698148900000, + "externalPurchaseToken": { + "externalPurchaseId": "SANDBOX_b2158121-7af9-49d4-9561-1f588205523e", + "tokenCreationDate": 1698148950000, + "appAppleId": 55555, + "bundleId": "com.example" + } +} \ No newline at end of file diff --git a/src/primitives/external_purchase_token.rs b/src/primitives/external_purchase_token.rs new file mode 100644 index 0000000..b17b679 --- /dev/null +++ b/src/primitives/external_purchase_token.rs @@ -0,0 +1,37 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::formats::Flexible; +use serde_with::TimestampMilliSeconds; + +/// The payload data that contains an external purchase token. +/// +/// [externalPurchaseToken](https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken) +#[serde_with::serde_as] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ExternalPurchaseToken { + /// The field of an external purchase token that uniquely identifies the token. + /// + /// [externalPurchaseId](https://developer.apple.com/documentation/appstoreservernotifications/externalpurchaseid) + #[serde(rename = "externalPurchaseId")] + pub external_purchase_id: Option, + + /// The field of an external purchase token that contains the UNIX date, in milliseconds, + /// when the system created the token. + /// + /// [tokenCreationDate](https://developer.apple.com/documentation/appstoreservernotifications/tokencreationdate) + #[serde(rename = "tokenCreationDate")] + #[serde_as(as = "Option>")] + pub token_creation_date: Option>, + + /// The unique identifier of an app in the App Store. + /// + /// [appAppleId](https://developer.apple.com/documentation/appstoreservernotifications/appappleid) + #[serde(rename = "appAppleId")] + pub app_apple_id: Option, + + /// The bundle identifier of an app. + /// + /// [bundleId](https://developer.apple.com/documentation/appstoreservernotifications/bundleid) + #[serde(rename = "bundleId")] + pub bundle_id: Option, +} \ No newline at end of file diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 5a1c1d0..ca71142 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -51,3 +51,4 @@ pub mod transaction_history_request; pub mod transaction_info_response; pub mod transaction_reason; pub mod user_status; +pub mod external_purchase_token; diff --git a/src/primitives/notification_type_v2.rs b/src/primitives/notification_type_v2.rs index 35a4d27..42cae5b 100644 --- a/src/primitives/notification_type_v2.rs +++ b/src/primitives/notification_type_v2.rs @@ -39,4 +39,6 @@ pub enum NotificationTypeV2 { RenewalExtension, #[serde(rename = "REFUND_REVERSED")] RefundReversed, + #[serde(rename = "EXTERNAL_PURCHASE_TOKEN")] + ExternalPurchaseToken, } diff --git a/src/primitives/response_body_v2_decoded_payload.rs b/src/primitives/response_body_v2_decoded_payload.rs index a36d1da..324b340 100644 --- a/src/primitives/response_body_v2_decoded_payload.rs +++ b/src/primitives/response_body_v2_decoded_payload.rs @@ -5,6 +5,7 @@ use crate::primitives::summary::Summary; use ::chrono::{DateTime, Utc}; use serde_with::formats::Flexible; use serde_with::TimestampMilliSeconds; +use crate::primitives::external_purchase_token::ExternalPurchaseToken; /// A decoded payload containing the version 2 notification data. /// @@ -31,7 +32,7 @@ pub struct ResponseBodyV2DecodedPayload { pub notification_uuid: String, /// The object that contains the app metadata and signed renewal and transaction information. - /// The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both. + /// The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. /// /// [data](https://developer.apple.com/documentation/appstoreservernotifications/data) pub data: Option, @@ -49,8 +50,15 @@ pub struct ResponseBodyV2DecodedPayload { pub signed_date: Option>, /// The summary data that appears when the App Store server completes your request to extend a subscription renewal date for eligible subscribers. - /// The data and summary fields are mutually exclusive. The payload contains one of the fields, but not both. + /// The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. /// /// [summary](https://developer.apple.com/documentation/appstoreservernotifications/summary) pub summary: Option, + + /// This field appears when the notificationType is EXTERNAL_PURCHASE_TOKEN. + /// The data, summary, and externalPurchaseToken fields are mutually exclusive. The payload contains only one of these fields. + /// + /// [externalPurchaseToken](https://developer.apple.com/documentation/appstoreservernotifications/externalpurchasetoken) + #[serde(rename = "externalPurchaseToken")] + pub external_purchase_token: Option } diff --git a/src/primitives/subtype.rs b/src/primitives/subtype.rs index d195b0b..2ec7a28 100644 --- a/src/primitives/subtype.rs +++ b/src/primitives/subtype.rs @@ -37,4 +37,6 @@ pub enum Subtype { Summary, #[serde(rename = "FAILURE")] Failure, + #[serde(rename = "UNREPORTED")] + Unreported, } diff --git a/src/signed_data_verifier.rs b/src/signed_data_verifier.rs index d67dcbb..895a679 100644 --- a/src/signed_data_verifier.rs +++ b/src/signed_data_verifier.rs @@ -156,22 +156,53 @@ impl SignedDataVerifier { bundle_id = summary.bundle_id.clone(); app_apple_id = summary.app_apple_id.clone(); environment = summary.environment.clone(); + } else if let Some(external_purchase_token) = &decoded_signed_notification.external_purchase_token { + bundle_id = external_purchase_token.bundle_id.clone(); + app_apple_id = external_purchase_token.app_apple_id.clone(); + + if let Some(external_purchase_id) = &external_purchase_token.external_purchase_id { + if external_purchase_id.starts_with("SANDBOX") { + environment = Some(Environment::Sandbox) + } else { + environment = Some(Environment::Production) + } + } else { + environment = Some(Environment::Production) + } } else { - return Err(SignedDataVerifierError::InvalidAppIdentifier); + bundle_id = None; + app_apple_id = None; + environment = None; + } + + self.verify_notification_app_identifier_and_environment(bundle_id, app_apple_id, environment)?; + + Ok(decoded_signed_notification) + } + + fn verify_notification_app_identifier_and_environment( + &self, + bundle_id: Option, + app_apple_id: Option, + environment: Option, + ) -> Result<(), SignedDataVerifierError> { + if let Some(bundle_id) = bundle_id { + if bundle_id != self.bundle_id { + return Err(SignedDataVerifierError::InvalidAppIdentifier); + } } - if bundle_id.as_ref() != Some(&self.bundle_id) - || (self.environment == Environment::Production - && app_apple_id.as_ref() != self.app_apple_id.as_ref()) - { + if self.environment == Environment::Production && self.app_apple_id != app_apple_id { return Err(SignedDataVerifierError::InvalidAppIdentifier); } - if environment.as_ref() != Some(&self.environment) { - return Err(SignedDataVerifierError::InvalidEnvironment); + if let Some(environment) = environment { + if self.environment != Environment::LocalTesting && self.environment != environment { + return Err(SignedDataVerifierError::InvalidEnvironment); + } } - Ok(decoded_signed_notification) + Ok(()) } /// Verifies and decodes a signed notification. @@ -356,6 +387,80 @@ mod tests { assert_eq!(renewal_info.environment, Some(Environment::Sandbox)); } + #[test] + fn test_external_purchase_token_notification_decoding() { + let signed_notification = + create_signed_data_from_json("assets/signedExternalPurchaseTokenNotification.json"); + + let signed_data_verifier = get_signed_data_verifier(Environment::LocalTesting, "com.example", Some(55555)); + + match signed_data_verifier.verify_and_decode_notification(&signed_notification) { + Ok(notification) => { + + assert_eq!(NotificationTypeV2::ExternalPurchaseToken, notification.notification_type); + assert_eq!(Subtype::Unreported, notification.subtype.expect("Expect subtype")); + assert_eq!("002e14d5-51f5-4503-b5a8-c3a1af68eb20", ¬ification.notification_uuid); + assert_eq!("2.0", ¬ification.version.expect("Expect version")); + assert_eq!( + 1698148900, + notification.signed_date.expect("Expect signed_date").timestamp() + ); + assert!(notification.data.is_none()); + assert!(notification.summary.is_none()); + assert!(notification.external_purchase_token.is_some()); + + if let Some(external_purchase_token) = notification.external_purchase_token { + assert_eq!("b2158121-7af9-49d4-9561-1f588205523e", &external_purchase_token.external_purchase_id.expect("Expect external_purchase_id")); + assert_eq!(1698148950, external_purchase_token.token_creation_date.unwrap().timestamp()); + assert_eq!(55555, external_purchase_token.app_apple_id.unwrap()); + assert_eq!("com.example", &external_purchase_token.bundle_id.unwrap()); + } else { + panic!("External purchase token is expected to be Some, but it was None"); + } + } + Err(err) => { + panic!("Failed to verify and decode app transaction: {:?}", err) + } + } + } + + #[test] + fn test_external_purchase_token_sanbox_notification_decoding() { + let signed_notification = + create_signed_data_from_json("assets/signedExternalPurchaseTokenSandboxNotification.json"); + + let signed_data_verifier = get_signed_data_verifier(Environment::LocalTesting, "com.example", Some(55555)); + + match signed_data_verifier.verify_and_decode_notification(&signed_notification) { + Ok(notification) => { + + assert_eq!(NotificationTypeV2::ExternalPurchaseToken, notification.notification_type); + assert_eq!(Subtype::Unreported, notification.subtype.expect("Expect subtype")); + assert_eq!("002e14d5-51f5-4503-b5a8-c3a1af68eb20", ¬ification.notification_uuid); + assert_eq!("2.0", ¬ification.version.expect("Expect version")); + assert_eq!( + 1698148900, + notification.signed_date.expect("Expect signed_date").timestamp() + ); + assert!(notification.data.is_none()); + assert!(notification.summary.is_none()); + assert!(notification.external_purchase_token.is_some()); + + if let Some(external_purchase_token) = notification.external_purchase_token { + assert_eq!("SANDBOX_b2158121-7af9-49d4-9561-1f588205523e", &external_purchase_token.external_purchase_id.expect("Expect external_purchase_id")); + assert_eq!(1698148950, external_purchase_token.token_creation_date.unwrap().timestamp()); + assert_eq!(55555, external_purchase_token.app_apple_id.unwrap()); + assert_eq!("com.example", &external_purchase_token.bundle_id.unwrap()); + } else { + panic!("External purchase token is expected to be Some, but it was None"); + } + } + Err(err) => { + panic!("Failed to verify and decode app transaction: {:?}", err) + } + } + } + #[test] fn test_transaction_info_decoding() { let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); @@ -766,6 +871,7 @@ mod tests { ); assert!(notification.data.is_some()); assert!(notification.summary.is_none()); + assert!(notification.external_purchase_token.is_none()); if let Some(data) = notification.data { assert_eq!( @@ -838,6 +944,7 @@ mod tests { ); assert!(notification.data.is_none()); assert!(notification.summary.is_some()); + assert!(notification.external_purchase_token.is_none()); if let Some(summary) = notification.summary { assert_eq!( From 4f33ef751e8895f8be9c94995e117e7288203594 Mon Sep 17 00:00:00 2001 From: tikhop Date: Wed, 3 Apr 2024 15:45:29 +0200 Subject: [PATCH 3/4] chore: Update deps --- Cargo.toml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2908a3b..008a6ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,36 +14,36 @@ edition = "2021" [dependencies] # Cryptography -x509-parser = { version = "0.15.1", features = ["verify", "validate"] } -jsonwebtoken = { version = "9.2.0" } -ring = "0.17.7" +x509-parser = { version = "0.16.0", features = ["verify", "validate"] } +jsonwebtoken = { version = "9.3.0" } +ring = "0.17.8" pem = "3.0.3" # Serialization -serde = { version = "1.0.195", features = ["derive"] } -serde_json = { version = "1.0.111" } -serde_with = { version = "3.5.0", features = ["chrono"] } +serde = { version = "1.0.197", features = ["derive"] } +serde_json = { version = "1.0.115" } +serde_with = { version = "3.7.0", features = ["chrono"] } serde_repr = "0.1.18" -uuid = { version = "1.7.0", features = ["serde", "v4"] } -chrono = { version = "0.4.32", features = ["serde"] } -base64 = "0.21.7" -asn1-rs = { version = "0.5.2", optional = true } +uuid = { version = "1.8.0", features = ["serde", "v4"] } +chrono = { version = "0.4.37", features = ["serde"] } +base64 = "0.22.0" +asn1-rs = { version = "0.6.1", optional = true } # Networking -reqwest = { version = "0.11.23", features = ["json"], optional = true } +reqwest = { version = "0.12.2", features = ["json"], optional = true } # Utils -thiserror = "1.0.56" +thiserror = "1.0.58" # Tools -regex = { version = "1.10.3", optional = true } +regex = { version = "1.10.4", optional = true } url = "2.5.0" [dev-dependencies] -http = "1.0.0" -tokio = { version = "1.35.1", features = ["test-util", "macros"] } -jsonwebtoken = { version = "9.2.0", features = ["use_pem"] } +http = "1.1.0" +tokio = { version = "1.37.0", features = ["test-util", "macros"] } +jsonwebtoken = { version = "9.3.0", features = ["use_pem"] } [features] api-client = ["dep:reqwest"] From 4767fddafa8d5e11d2c7465b9d8c478ad875ab8d Mon Sep 17 00:00:00 2001 From: tikhop Date: Wed, 3 Apr 2024 15:58:42 +0200 Subject: [PATCH 4/4] chore: Fix warnings --- src/api_client.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/api_client.rs b/src/api_client.rs index 6af1d6a..0b2cde4 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -581,7 +581,6 @@ mod tests { use http::StatusCode; use serde_json::Value; use chrono::DateTime; - use chrono::NaiveDateTime; use url::Url; use uuid::Uuid; use base64::prelude::BASE64_STANDARD_NO_PAD; @@ -750,11 +749,11 @@ mod tests { let send_attempt_items = vec![ SendAttemptItem { - attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148900, 0).unwrap(), Utc).into(), + attempt_date: DateTime::from_timestamp(1698148900, 0), send_attempt_result: SendAttemptResult::NoResponse.into(), }, SendAttemptItem { - attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148950, 0).unwrap(), Utc).into(), + attempt_date: DateTime::from_timestamp(1698148950, 0), send_attempt_result: SendAttemptResult::Success.into(), }, ]; @@ -777,8 +776,8 @@ mod tests { })); let notification_history_request = NotificationHistoryRequest { - start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148900, 0).unwrap(), Utc).into(), - end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148950, 0).unwrap(), Utc).into(), + start_date:DateTime::from_timestamp(1698148900, 0), + end_date:DateTime::from_timestamp(1698148950, 0), notification_type: NotificationTypeV2::Subscribed.into(), notification_subtype: Subtype::InitialBuy.into(), transaction_id: "999733843".to_string().into(), @@ -794,11 +793,11 @@ mod tests { signed_payload: "signed_payload_one".to_string().into(), send_attempts: vec![ SendAttemptItem { - attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148900, 0).unwrap(), Utc).into(), + attempt_date: DateTime::from_timestamp(1698148900, 0), send_attempt_result: SendAttemptResult::NoResponse.into(), }, SendAttemptItem { - attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148950, 0).unwrap(), Utc).into(), + attempt_date: DateTime::from_timestamp(1698148950, 0), send_attempt_result: SendAttemptResult::Success.into(), }, ].into(), @@ -807,7 +806,7 @@ mod tests { signed_payload: "signed_payload_two".to_string().into(), send_attempts: vec![ SendAttemptItem { - attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148800, 0).unwrap(), Utc).into(), + attempt_date: DateTime::from_timestamp(1698148800, 0), send_attempt_result: SendAttemptResult::CircularRedirect.into(), }, ].into(), @@ -839,8 +838,8 @@ mod tests { })); let request = TransactionHistoryRequest { - start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 455000000).unwrap(), Utc).into(), - end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 456000000).unwrap(), Utc).into(), + start_date: DateTime::from_timestamp(123, 455000000), + end_date: DateTime::from_timestamp(123, 456000000), product_ids: vec!["com.example.1", "com.example.2"].into_iter().map(String::from).collect::>().into(), product_types: vec![ProductType::Consumable, ProductType::AutoRenewable].into(), sort: Order::Ascending.into(), @@ -1014,8 +1013,8 @@ mod tests { let client = app_store_server_api_client_with_body_from_file("assets/models/transactionHistoryResponseWithMalformedEnvironment.json", StatusCode::OK, None); let request = TransactionHistoryRequest { - start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 455000000).unwrap(), Utc).into(), - end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 456000000).unwrap(), Utc).into(), + start_date: DateTime::from_timestamp(123, 455000000), + end_date: DateTime::from_timestamp(123, 456000000), product_ids: vec!["com.example.1".to_string(), "com.example.2".to_string()].into(), product_types: vec![ProductType::Consumable, ProductType::AutoRenewable].into(), sort: Some(Order::Ascending), @@ -1033,8 +1032,8 @@ mod tests { let client = app_store_server_api_client_with_body_from_file("assets/models/transactionHistoryResponseWithMalformedAppAppleId.json", StatusCode::OK, None); let request = TransactionHistoryRequest { - start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 455000000).unwrap(), Utc).into(), - end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 456000000).unwrap(), Utc).into(), + start_date: DateTime::from_timestamp(123, 455000000), + end_date: DateTime::from_timestamp(123, 456000000), product_ids: vec!["com.example.1".to_string(), "com.example.2".to_string()].into(), product_types: vec![ProductType::Consumable, ProductType::AutoRenewable].into(), sort: Some(Order::Ascending),