From a3472fa5644d473f4b852050d845be2d424a7b78 Mon Sep 17 00:00:00 2001 From: tikhop Date: Wed, 3 Apr 2024 15:43:58 +0200 Subject: [PATCH] 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!(