Skip to content

Commit

Permalink
feat: Support App Store Server Notifications v2.10
Browse files Browse the repository at this point in the history
  • Loading branch information
tikhop committed Apr 3, 2024
1 parent 7fbbcc9 commit a3472fa
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 10 deletions.
13 changes: 13 additions & 0 deletions assets/signedExternalPurchaseTokenNotification.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
13 changes: 13 additions & 0 deletions assets/signedExternalPurchaseTokenSandboxNotification.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
37 changes: 37 additions & 0 deletions src/primitives/external_purchase_token.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

/// 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<TimestampMilliSeconds<String, Flexible>>")]
pub token_creation_date: Option<DateTime<Utc>>,

/// 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<i64>,

/// The bundle identifier of an app.
///
/// [bundleId](https://developer.apple.com/documentation/appstoreservernotifications/bundleid)
#[serde(rename = "bundleId")]
pub bundle_id: Option<String>,
}
1 change: 1 addition & 0 deletions src/primitives/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions src/primitives/notification_type_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ pub enum NotificationTypeV2 {
RenewalExtension,
#[serde(rename = "REFUND_REVERSED")]
RefundReversed,
#[serde(rename = "EXTERNAL_PURCHASE_TOKEN")]
ExternalPurchaseToken,
}
12 changes: 10 additions & 2 deletions src/primitives/response_body_v2_decoded_payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<Data>,
Expand All @@ -49,8 +50,15 @@ pub struct ResponseBodyV2DecodedPayload {
pub signed_date: Option<DateTime<Utc>>,

/// 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<Summary>,

/// 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<ExternalPurchaseToken>
}
2 changes: 2 additions & 0 deletions src/primitives/subtype.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ pub enum Subtype {
Summary,
#[serde(rename = "FAILURE")]
Failure,
#[serde(rename = "UNREPORTED")]
Unreported,
}
123 changes: 115 additions & 8 deletions src/signed_data_verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
app_apple_id: Option<i64>,
environment: Option<Environment>,
) -> 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.
Expand Down Expand Up @@ -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", &notification.notification_uuid);
assert_eq!("2.0", &notification.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", &notification.notification_uuid);
assert_eq!("2.0", &notification.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);
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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!(
Expand Down

0 comments on commit a3472fa

Please sign in to comment.