From 86458b9094d15d83275a66aeee2ba619f66a401d Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Fri, 15 Mar 2024 10:17:36 -0700 Subject: [PATCH] Support App Store Server Notifications v2.10 https://developer.apple.com/documentation/appstoreservernotifications?changes=latest_minor --- .../storekit/model/ExternalPurchaseToken.java | 161 ++++++++++++++++++ .../storekit/model/NotificationTypeV2.java | 3 +- .../model/ResponseBodyV2DecodedPayload.java | 30 +++- .../apple/itunes/storekit/model/Subtype.java | 3 +- .../verification/SignedDataVerifier.java | 34 +++- .../ResponseBodyV2DecodedPayloadTest.java | 68 +++++++- ...gnedExternalPurchaseTokenNotification.json | 13 ++ ...ernalPurchaseTokenSandboxNotification.json | 13 ++ 8 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/apple/itunes/storekit/model/ExternalPurchaseToken.java create mode 100644 src/test/resources/models/signedExternalPurchaseTokenNotification.json create mode 100644 src/test/resources/models/signedExternalPurchaseTokenSandboxNotification.json diff --git a/src/main/java/com/apple/itunes/storekit/model/ExternalPurchaseToken.java b/src/main/java/com/apple/itunes/storekit/model/ExternalPurchaseToken.java new file mode 100644 index 00000000..cc85465d --- /dev/null +++ b/src/main/java/com/apple/itunes/storekit/model/ExternalPurchaseToken.java @@ -0,0 +1,161 @@ +// Copyright (c) 2024 Apple Inc. Licensed under MIT License. + +package com.apple.itunes.storekit.model; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; +import java.util.Objects; + +/** + * The payload data that contains an external purchase token. + * + * @see externalPurchaseToken + */ +public class ExternalPurchaseToken { + private static final String SERIALIZED_NAME_EXTERNAL_PURCHASE_ID = "externalPurchaseId"; + private static final String SERIALIZED_NAME_TOKEN_CREATION_DATE = "tokenCreationDate"; + private static final String SERIALIZED_NAME_APP_APPLE_ID = "appAppleId"; + private static final String SERIALIZED_NAME_BUNDLE_ID = "bundleId"; + @JsonProperty(SERIALIZED_NAME_EXTERNAL_PURCHASE_ID) + private String externalPurchaseId; + @JsonProperty(SERIALIZED_NAME_TOKEN_CREATION_DATE) + private Long tokenCreationDate; + @JsonProperty(SERIALIZED_NAME_APP_APPLE_ID) + private Long appAppleId; + @JsonProperty(SERIALIZED_NAME_BUNDLE_ID) + private String bundleId; + @JsonAnySetter + private Map unknownFields; + + public ExternalPurchaseToken() { + } + + public ExternalPurchaseToken externalPurchaseId(String externalPurchaseId) { + this.externalPurchaseId = externalPurchaseId; + return this; + } + + /** + * The field of an external purchase token that uniquely identifies the token. + * + * @return externalPurchaseId + * @see externalPurchaseId + **/ + public String getExternalPurchaseId() { + return externalPurchaseId; + } + + public void setExternalPurchaseId(String externalPurchaseId) { + this.externalPurchaseId = externalPurchaseId; + } + + public ExternalPurchaseToken tokenCreationDate(Long tokenCreationDate) { + this.tokenCreationDate = tokenCreationDate; + return this; + } + + /** + * The field of an external purchase token that contains the UNIX date, in milliseconds, when the system created the token. + * + * @return tokenCreationDate + * @see tokenCreationDate + **/ + public Long getTokenCreationDate() { + return tokenCreationDate; + } + + public void setTokenCreationDate(Long tokenCreationDate) { + this.tokenCreationDate = tokenCreationDate; + } + + public ExternalPurchaseToken appAppleId(Long appAppleId) { + this.appAppleId = appAppleId; + return this; + } + + /** + * The unique identifier of an app in the App Store. + * + * @return appAppleId + * @see appAppleId + **/ + public Long getAppAppleId() { + return appAppleId; + } + + public void setAppAppleId(Long appAppleId) { + this.appAppleId = appAppleId; + } + + public ExternalPurchaseToken bundleId(String bundleId) { + this.bundleId = bundleId; + return this; + } + + /** + * The bundle identifier of an app. + * + * @return bundleId + * @see bundleId + **/ + public String getBundleId() { + return bundleId; + } + + public void setBundleId(String bundleId) { + this.bundleId = bundleId; + } + + public ExternalPurchaseToken unknownFields(Map unknownFields) { + this.unknownFields = unknownFields; + return this; + } + + /** + Fields that are not recognized for this object + + @return A map of JSON keys to objects + */ + public Map getUnknownFields() { + return unknownFields; + } + + public void setUnknownFields(Map unknownFields) { + this.unknownFields = unknownFields; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ExternalPurchaseToken externalPurchaseToken = (ExternalPurchaseToken) o; + return Objects.equals(this.externalPurchaseId, externalPurchaseToken.externalPurchaseId) && + Objects.equals(this.tokenCreationDate, externalPurchaseToken.tokenCreationDate) && + Objects.equals(this.appAppleId, externalPurchaseToken.appAppleId) && + Objects.equals(this.bundleId, externalPurchaseToken.bundleId) && + Objects.equals(this.unknownFields, externalPurchaseToken.unknownFields); + } + + @Override + public int hashCode() { + return Objects.hash(externalPurchaseId, tokenCreationDate, appAppleId, bundleId, unknownFields); + } + + @Override + public String toString() { + return "ExternalPurchaseToken{" + + "externalPurchaseId='" + externalPurchaseId + '\'' + + ", tokenCreationDate=" + tokenCreationDate + + ", appAppleId=" + appAppleId + + ", bundleId='" + bundleId + '\'' + + ", unknownFields=" + unknownFields + + '}'; + } +} + diff --git a/src/main/java/com/apple/itunes/storekit/model/NotificationTypeV2.java b/src/main/java/com/apple/itunes/storekit/model/NotificationTypeV2.java index 2b030747..dd34dc05 100644 --- a/src/main/java/com/apple/itunes/storekit/model/NotificationTypeV2.java +++ b/src/main/java/com/apple/itunes/storekit/model/NotificationTypeV2.java @@ -27,7 +27,8 @@ public enum NotificationTypeV2 { REVOKE("REVOKE"), TEST("TEST"), RENEWAL_EXTENSION("RENEWAL_EXTENSION"), - REFUND_REVERSED("REFUND_REVERSED"); + REFUND_REVERSED("REFUND_REVERSED"), + EXTERNAL_PURCHASE_TOKEN("EXTERNAL_PURCHASE_TOKEN"); private final String value; diff --git a/src/main/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayload.java b/src/main/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayload.java index 6cf9ae1b..2b5b636e 100644 --- a/src/main/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayload.java +++ b/src/main/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayload.java @@ -21,6 +21,7 @@ public class ResponseBodyV2DecodedPayload implements DecodedSignedData { private static final String SERIALIZED_NAME_VERSION = "version"; private static final String SERIALIZED_NAME_SIGNED_DATE = "signedDate"; private static final String SERIALIZED_NAME_SUMMARY = "summary"; + private static final String SERIALIZED_NAME_EXTERNAL_PURCHASE_TOKEN = "externalPurchaseToken"; @JsonProperty(SERIALIZED_NAME_NOTIFICATION_TYPE) private String notificationType; @JsonProperty(SERIALIZED_NAME_SUBTYPE) @@ -35,6 +36,8 @@ public class ResponseBodyV2DecodedPayload implements DecodedSignedData { private Long signedDate; @JsonProperty(SERIALIZED_NAME_SUMMARY) private Summary summary; + @JsonProperty(SERIALIZED_NAME_EXTERNAL_PURCHASE_TOKEN) + private ExternalPurchaseToken externalPurchaseToken; @JsonAnySetter private Map unknownFields; @@ -128,7 +131,7 @@ public ResponseBodyV2DecodedPayload data(Data data) { /** * 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. * * @return data * @see data @@ -186,7 +189,7 @@ public ResponseBodyV2DecodedPayload summary(Summary summary) { /** * 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. * * @return summary * @see summary @@ -199,6 +202,25 @@ public void setSummary(Summary summary) { this.summary = summary; } + public ResponseBodyV2DecodedPayload externalPurchaseToken(ExternalPurchaseToken externalPurchaseToken) { + this.externalPurchaseToken = externalPurchaseToken; + return this; + } + + /** + * 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. + * + * @return externalPurchaseToken + * @see externalPurchaseToken + **/ + public ExternalPurchaseToken getExternalPurchaseToken() { + return externalPurchaseToken; + } + + public void setExternalPurchaseToken(ExternalPurchaseToken externalPurchaseToken) { + this.externalPurchaseToken = externalPurchaseToken; + } public ResponseBodyV2DecodedPayload unknownFields(Map unknownFields) { this.unknownFields = unknownFields; @@ -234,12 +256,13 @@ public boolean equals(Object o) { Objects.equals(this.version, responseBodyV2DecodedPayload.version) && Objects.equals(this.signedDate, responseBodyV2DecodedPayload.signedDate) && Objects.equals(this.summary, responseBodyV2DecodedPayload.summary) && + Objects.equals(this.externalPurchaseToken, responseBodyV2DecodedPayload.externalPurchaseToken) && Objects.equals(this.unknownFields, responseBodyV2DecodedPayload.unknownFields); } @Override public int hashCode() { - return Objects.hash(notificationType, subtype, notificationUUID, data, version, signedDate, summary, unknownFields); + return Objects.hash(notificationType, subtype, notificationUUID, data, version, signedDate, summary, externalPurchaseToken, unknownFields); } @Override @@ -252,6 +275,7 @@ public String toString() { ", version='" + version + '\'' + ", signedDate=" + signedDate + ", summary=" + summary + + ", externalPurchaseToken=" + externalPurchaseToken + ", unknownFields=" + unknownFields + '}'; } diff --git a/src/main/java/com/apple/itunes/storekit/model/Subtype.java b/src/main/java/com/apple/itunes/storekit/model/Subtype.java index 7c0ec447..1c2f1656 100644 --- a/src/main/java/com/apple/itunes/storekit/model/Subtype.java +++ b/src/main/java/com/apple/itunes/storekit/model/Subtype.java @@ -26,7 +26,8 @@ public enum Subtype { BILLING_RECOVERY("BILLING_RECOVERY"), PRODUCT_NOT_FOR_SALE("PRODUCT_NOT_FOR_SALE"), SUMMARY("SUMMARY"), - FAILURE("FAILURE"); + FAILURE("FAILURE"), + UNREPORTED("UNREPORTED"); private final String value; diff --git a/src/main/java/com/apple/itunes/storekit/verification/SignedDataVerifier.java b/src/main/java/com/apple/itunes/storekit/verification/SignedDataVerifier.java index 506aa401..a3342c31 100644 --- a/src/main/java/com/apple/itunes/storekit/verification/SignedDataVerifier.java +++ b/src/main/java/com/apple/itunes/storekit/verification/SignedDataVerifier.java @@ -102,16 +102,42 @@ public JWSRenewalInfoDecodedPayload verifyAndDecodeRenewalInfo(String signedRene */ public ResponseBodyV2DecodedPayload verifyAndDecodeNotification(String signedPayload) throws VerificationException { ResponseBodyV2DecodedPayload notification = decodeSignedObject(signedPayload, ResponseBodyV2DecodedPayload.class); - Environment notificationEnv = notification.getData() != null ? notification.getData().getEnvironment() : (notification.getSummary() != null ? notification.getSummary().getEnvironment() : null); - Long appAppleId = notification.getData() != null ? notification.getData().getAppAppleId() : (notification.getSummary() != null ? notification.getSummary().getAppAppleId() : null); - String bundleId = notification.getData() != null ? notification.getData().getBundleId() : (notification.getSummary() != null ? notification.getSummary().getBundleId() : null); + String bundleId; + Long appAppleId; + Environment notificationEnv; + if (notification.getData() != null) { + bundleId = notification.getData().getBundleId(); + appAppleId = notification.getData().getAppAppleId(); + notificationEnv = notification.getData().getEnvironment(); + } else if (notification.getSummary() != null) { + bundleId = notification.getSummary().getBundleId(); + appAppleId = notification.getSummary().getAppAppleId(); + notificationEnv = notification.getSummary().getEnvironment(); + } else if (notification.getExternalPurchaseToken() != null) { + bundleId = notification.getExternalPurchaseToken().getBundleId(); + appAppleId = notification.getExternalPurchaseToken().getAppAppleId(); + String externalPurchaseId = notification.getExternalPurchaseToken().getExternalPurchaseId(); + if (externalPurchaseId != null && externalPurchaseId.startsWith("SANDBOX")) { + notificationEnv = Environment.SANDBOX; + } else { + notificationEnv = Environment.PRODUCTION; + } + } else { + bundleId = null; + appAppleId = null; + notificationEnv = null; + } + verifyNotification(bundleId, appAppleId, notificationEnv); + return notification; + } + + protected void verifyNotification(String bundleId, Long appAppleId, Environment notificationEnv) throws VerificationException { if (!this.bundleId.equals(bundleId) || (this.environment.equals(Environment.PRODUCTION) && !this.appAppleId.equals(appAppleId))) { throw new VerificationException(VerificationStatus.INVALID_APP_IDENTIFIER); } if (!this.environment.equals(notificationEnv)) { throw new VerificationException(VerificationStatus.INVALID_ENVIRONMENT); } - return notification; } /** diff --git a/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java b/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java index 4ed22e1f..539ed211 100644 --- a/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java +++ b/src/test/java/com/apple/itunes/storekit/model/ResponseBodyV2DecodedPayloadTest.java @@ -4,15 +4,17 @@ import com.apple.itunes.storekit.util.SignedDataCreator; import com.apple.itunes.storekit.util.TestingUtility; +import com.apple.itunes.storekit.verification.SignedDataVerifier; import com.apple.itunes.storekit.verification.VerificationException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.security.NoSuchAlgorithmException; import java.security.spec.InvalidKeySpecException; import java.util.List; -import java.util.UUID; +import java.util.Set; public class ResponseBodyV2DecodedPayloadTest { @@ -31,6 +33,7 @@ public void testNotificationDecoding() throws IOException, NoSuchAlgorithmExcept Assertions.assertEquals(1698148900000L, notification.getSignedDate()); Assertions.assertNotNull(notification.getData()); Assertions.assertNull(notification.getSummary()); + Assertions.assertNull(notification.getExternalPurchaseToken()); Assertions.assertEquals(Environment.LOCAL_TESTING, notification.getData().getEnvironment()); Assertions.assertEquals("LocalTesting", notification.getData().getRawEnvironment()); Assertions.assertEquals(41234L, notification.getData().getAppAppleId()); @@ -57,6 +60,7 @@ public void testSummaryNotificationDecoding() throws IOException, NoSuchAlgorith Assertions.assertEquals(1698148900000L, notification.getSignedDate()); Assertions.assertNull(notification.getData()); Assertions.assertNotNull(notification.getSummary()); + Assertions.assertNull(notification.getExternalPurchaseToken()); Assertions.assertEquals(Environment.LOCAL_TESTING, notification.getSummary().getEnvironment()); Assertions.assertEquals("LocalTesting", notification.getSummary().getRawEnvironment()); Assertions.assertEquals(41234L, notification.getSummary().getAppAppleId()); @@ -67,4 +71,66 @@ public void testSummaryNotificationDecoding() throws IOException, NoSuchAlgorith Assertions.assertEquals(5, notification.getSummary().getSucceededCount()); Assertions.assertEquals(2, notification.getSummary().getFailedCount()); } + + @Test + public void testExternalPurchaseTokenNotificationDecoding() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, VerificationException { + String signedNotification = SignedDataCreator.createSignedDataFromJson("models/signedExternalPurchaseTokenNotification.json"); + + SignedDataVerifier verifier = new SignedDataVerifier(Set.of(new ByteArrayInputStream(TestingUtility.readBytes("certs/testCA.der"))), "com.example", 1234L, Environment.LOCAL_TESTING, false) { + @Override + protected void verifyNotification(String bundleId, Long appAppleId, Environment notificationEnv) throws VerificationException { + Assertions.assertEquals("com.example", bundleId); + Assertions.assertEquals(55555, appAppleId); + Assertions.assertEquals(Environment.PRODUCTION, notificationEnv); + } + }; + + ResponseBodyV2DecodedPayload notification = verifier.verifyAndDecodeNotification(signedNotification); + + Assertions.assertEquals(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN, notification.getNotificationType()); + Assertions.assertEquals("EXTERNAL_PURCHASE_TOKEN", notification.getRawNotificationType()); + Assertions.assertEquals(Subtype.UNREPORTED, notification.getSubtype()); + Assertions.assertEquals("UNREPORTED", notification.getRawSubtype()); + Assertions.assertEquals("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.getNotificationUUID()); + Assertions.assertEquals("2.0", notification.getVersion()); + Assertions.assertEquals(1698148900000L, notification.getSignedDate()); + Assertions.assertNull(notification.getData()); + Assertions.assertNull(notification.getSummary()); + Assertions.assertNotNull(notification.getExternalPurchaseToken()); + Assertions.assertEquals("b2158121-7af9-49d4-9561-1f588205523e", notification.getExternalPurchaseToken().getExternalPurchaseId()); + Assertions.assertEquals(1698148950000L, notification.getExternalPurchaseToken().getTokenCreationDate()); + Assertions.assertEquals(55555L, notification.getExternalPurchaseToken().getAppAppleId()); + Assertions.assertEquals("com.example", notification.getExternalPurchaseToken().getBundleId()); + } + + @Test + public void testExternalPurchaseTokenSandboxNotificationDecoding() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, VerificationException { + String signedNotification = SignedDataCreator.createSignedDataFromJson("models/signedExternalPurchaseTokenSandboxNotification.json"); + + SignedDataVerifier verifier = new SignedDataVerifier(Set.of(new ByteArrayInputStream(TestingUtility.readBytes("certs/testCA.der"))), "com.example", 1234L, Environment.LOCAL_TESTING, false) { + @Override + protected void verifyNotification(String bundleId, Long appAppleId, Environment notificationEnv) throws VerificationException { + Assertions.assertEquals("com.example", bundleId); + Assertions.assertEquals(55555, appAppleId); + Assertions.assertEquals(Environment.SANDBOX, notificationEnv); + } + }; + + ResponseBodyV2DecodedPayload notification = verifier.verifyAndDecodeNotification(signedNotification); + + Assertions.assertEquals(NotificationTypeV2.EXTERNAL_PURCHASE_TOKEN, notification.getNotificationType()); + Assertions.assertEquals("EXTERNAL_PURCHASE_TOKEN", notification.getRawNotificationType()); + Assertions.assertEquals(Subtype.UNREPORTED, notification.getSubtype()); + Assertions.assertEquals("UNREPORTED", notification.getRawSubtype()); + Assertions.assertEquals("002e14d5-51f5-4503-b5a8-c3a1af68eb20", notification.getNotificationUUID()); + Assertions.assertEquals("2.0", notification.getVersion()); + Assertions.assertEquals(1698148900000L, notification.getSignedDate()); + Assertions.assertNull(notification.getData()); + Assertions.assertNull(notification.getSummary()); + Assertions.assertNotNull(notification.getExternalPurchaseToken()); + Assertions.assertEquals("SANDBOX_b2158121-7af9-49d4-9561-1f588205523e", notification.getExternalPurchaseToken().getExternalPurchaseId()); + Assertions.assertEquals(1698148950000L, notification.getExternalPurchaseToken().getTokenCreationDate()); + Assertions.assertEquals(55555L, notification.getExternalPurchaseToken().getAppAppleId()); + Assertions.assertEquals("com.example", notification.getExternalPurchaseToken().getBundleId()); + } } diff --git a/src/test/resources/models/signedExternalPurchaseTokenNotification.json b/src/test/resources/models/signedExternalPurchaseTokenNotification.json new file mode 100644 index 00000000..479785b1 --- /dev/null +++ b/src/test/resources/models/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/src/test/resources/models/signedExternalPurchaseTokenSandboxNotification.json b/src/test/resources/models/signedExternalPurchaseTokenSandboxNotification.json new file mode 100644 index 00000000..509cdd64 --- /dev/null +++ b/src/test/resources/models/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