Skip to content

Commit

Permalink
Add support for Xcode and LocalTesting environments
Browse files Browse the repository at this point in the history
  • Loading branch information
alexanderjordanbaker committed Oct 27, 2023
1 parent 9467338 commit 776812f
Show file tree
Hide file tree
Showing 14 changed files with 228 additions and 4 deletions.
34 changes: 34 additions & 0 deletions src/main/java/com/apple/itunes/storekit/model/AppTransaction.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.apple.itunes.storekit.model;

import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;

import java.util.Objects;
import java.util.UUID;

/**
Expand Down Expand Up @@ -35,8 +37,10 @@ public class AppTransaction implements DecodedSignedData {
@SerializedName(SERIALIZED_NAME_VERSION_EXTERNAL_IDENTIFIER)
private Long versionExternalIdentifier;
@SerializedName(SERIALIZED_NAME_RECEIPT_CREATION_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long receiptCreationDate;
@SerializedName(SERIALIZED_NAME_ORIGINAL_PURCHASE_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long originalPurchaseDate;
@SerializedName(SERIALIZED_NAME_ORIGINAL_APPLICATION_VERSION)
private String originalApplicationVersion;
Expand All @@ -45,6 +49,7 @@ public class AppTransaction implements DecodedSignedData {
@SerializedName(SERIALIZED_NAME_DEVICE_VERIFICATION_NONCE)
private UUID deviceVerificationNonce;
@SerializedName(SERIALIZED_NAME_PREORDER_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long preorderDate;

/**
Expand Down Expand Up @@ -250,5 +255,34 @@ public AppTransaction preorderDate(Long preorderDate) {
public Long getSignedDate() {
return getReceiptCreationDate();
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AppTransaction that = (AppTransaction) o;
return Objects.equals(receiptType, that.receiptType) && Objects.equals(appAppleId, that.appAppleId) && Objects.equals(bundleId, that.bundleId) && Objects.equals(applicationVersion, that.applicationVersion) && Objects.equals(versionExternalIdentifier, that.versionExternalIdentifier) && Objects.equals(originalPurchaseDate, that.originalPurchaseDate) && Objects.equals(originalApplicationVersion, that.originalApplicationVersion) && Objects.equals(deviceVerification, that.deviceVerification) && Objects.equals(deviceVerificationNonce, that.deviceVerificationNonce) && Objects.equals(preorderDate, that.preorderDate);
}

@Override
public int hashCode() {
return Objects.hash(receiptType, appAppleId, bundleId, applicationVersion, versionExternalIdentifier, originalPurchaseDate, originalApplicationVersion, deviceVerification, deviceVerificationNonce, preorderDate);
}

@Override
public String toString() {
return "AppTransaction{" +
"receiptType='" + receiptType + '\'' +
", appAppleId=" + appAppleId +
", bundleId='" + bundleId + '\'' +
", applicationVersion='" + applicationVersion + '\'' +
", versionExternalIdentifier=" + versionExternalIdentifier +
", originalPurchaseDate=" + originalPurchaseDate +
", originalApplicationVersion='" + originalApplicationVersion + '\'' +
", deviceVerification='" + deviceVerification + '\'' +
", deviceVerificationNonce=" + deviceVerificationNonce +
", preorderDate=" + preorderDate +
'}';
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
public enum Environment {

SANDBOX("Sandbox"),
PRODUCTION("Production");
PRODUCTION("Production"),
XCODE("Xcode"),
LOCAL_TESTING("LocalTesting");

private final String value;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package com.apple.itunes.storekit.model;

import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;

import java.util.Objects;
Expand Down Expand Up @@ -41,18 +42,22 @@ public class JWSRenewalInfoDecodedPayload implements DecodedSignedData {
@SerializedName(SERIALIZED_NAME_PRICE_INCREASE_STATUS)
private PriceIncreaseStatus priceIncreaseStatus;
@SerializedName(SERIALIZED_NAME_GRACE_PERIOD_EXPIRES_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long gracePeriodExpiresDate;
@SerializedName(SERIALIZED_NAME_OFFER_TYPE)
private OfferType offerType;
@SerializedName(SERIALIZED_NAME_OFFER_IDENTIFIER)
private String offerIdentifier;
@SerializedName(SERIALIZED_NAME_SIGNED_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long signedDate;
@SerializedName(SERIALIZED_NAME_ENVIRONMENT)
private Environment environment;
@SerializedName(SERIALIZED_NAME_RECENT_SUBSCRIPTION_START_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long recentSubscriptionStartDate;
@SerializedName(SERIALIZED_NAME_RENEWAL_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long renewalDate;


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package com.apple.itunes.storekit.model;

import com.google.gson.annotations.JsonAdapter;
import com.google.gson.annotations.SerializedName;

import java.util.Objects;
Expand Down Expand Up @@ -49,10 +50,13 @@ public class JWSTransactionDecodedPayload implements DecodedSignedData {
@SerializedName(SERIALIZED_NAME_SUBSCRIPTION_GROUP_IDENTIFIER)
private String subscriptionGroupIdentifier;
@SerializedName(SERIALIZED_NAME_PURCHASE_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long purchaseDate;
@SerializedName(SERIALIZED_NAME_ORIGINAL_PURCHASE_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long originalPurchaseDate;
@SerializedName(SERIALIZED_NAME_EXPIRES_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long expiresDate;
@SerializedName(SERIALIZED_NAME_QUANTITY)
private Integer quantity;
Expand All @@ -63,10 +67,12 @@ public class JWSTransactionDecodedPayload implements DecodedSignedData {
@SerializedName(SERIALIZED_NAME_IN_APP_OWNERSHIP_TYPE)
private InAppOwnershipType inAppOwnershipType;
@SerializedName(SERIALIZED_NAME_SIGNED_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long signedDate;
@SerializedName(SERIALIZED_NAME_REVOCATION_REASON)
private RevocationReason revocationReason;
@SerializedName(SERIALIZED_NAME_REVOCATION_DATE)
@JsonAdapter(XcodeCompatibleTimestampDeserializer.class)
private Long revocationDate;
@SerializedName(SERIALIZED_NAME_IS_UPGRADED)
private Boolean isUpgraded;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) 2023 Apple Inc. Licensed under MIT License.

package com.apple.itunes.storekit.model;

import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;

import java.lang.reflect.Type;

/**
* Xcode may sometimes return timestamps as floating point numbers not integers. This class allows parsing those receipts
*/
class XcodeCompatibleTimestampDeserializer implements JsonDeserializer<Long> {
@Override
public Long deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return json.getAsJsonPrimitive().getAsNumber().longValue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ public JWSTransactionDecodedPayload verifyAndDecodeTransaction(String signedTran
* @throws VerificationException Thrown if the data could not be verified
*/
public JWSRenewalInfoDecodedPayload verifyAndDecodeRenewalInfo(String signedRenewalInfo) throws VerificationException {
return decodeSignedObject(signedRenewalInfo, JWSRenewalInfoDecodedPayload.class);
JWSRenewalInfoDecodedPayload renewalInfo = decodeSignedObject(signedRenewalInfo, JWSRenewalInfoDecodedPayload.class);
if (!this.environment.equals(renewalInfo.getEnvironment())) {
throw new VerificationException(Status.INVALID_ENVIRONMENT);
}
return renewalInfo;
}

/**
Expand Down Expand Up @@ -120,12 +124,16 @@ public AppTransaction verifyAndDecodeAppTransaction(String signedAppTransaction)
protected <T extends DecodedSignedData> T decodeSignedObject(String signedObject, Class<T> clazz) throws VerificationException {
try {
DecodedJWT unverifiedJWT = JWT.decode(signedObject);
if (Environment.XCODE.equals(this.environment) || Environment.LOCAL_TESTING.equals(this.environment)) {
// Data is not signed by the App Store, and verification should be skipped
// The environment MUST be checked in the public method calling this
return parseJWTPayload(clazz, unverifiedJWT);
}
String[] x5cChain = unverifiedJWT.getHeaderClaim("x5c").asArray(String.class);
if (x5cChain == null) {
throw new VerificationException(Status.VERIFICATION_FAILURE, "x5c claim was null");
}
String payload = new String(Base64.getUrlDecoder().decode(unverifiedJWT.getPayload()));
T decodedData = gson.fromJson(payload, clazz);
T decodedData = parseJWTPayload(clazz, unverifiedJWT);
Date effectiveDate = this.enableOnlineChecks || decodedData.getSignedDate() == null ? new Date() : new Date(decodedData.getSignedDate());
PublicKey signingKey = chainVerifier.verifyChain(x5cChain, enableOnlineChecks, effectiveDate);
if ("ES256".equals(unverifiedJWT.getAlgorithm())) {
Expand All @@ -140,4 +148,9 @@ protected <T extends DecodedSignedData> T decodeSignedObject(String signedObject
throw new VerificationException(Status.VERIFICATION_FAILURE, e);
}
}

protected <T extends DecodedSignedData> T parseJWTPayload(Class<T> clazz, DecodedJWT jwt) {
String payload = new String(Base64.getUrlDecoder().decode(jwt.getPayload()));
return gson.fromJson(payload, clazz);
}
}
33 changes: 33 additions & 0 deletions src/test/java/com/apple/itunes/storekit/util/TestingUtility.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) 2023 Apple Inc. Licensed under MIT License.

package com.apple.itunes.storekit.util;

import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.verification.SignedDataVerifier;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Set;

public class TestingUtility {

public static String readFile(String file) throws IOException {
return new String(readBytes(file), StandardCharsets.UTF_8);
}

public static byte[] readBytes(String file) throws IOException {
try (InputStream stream = TestingUtility.class.getClassLoader().getResourceAsStream(file)) {
return stream.readAllBytes();
}
}

public static SignedDataVerifier getSignedPayloadVerifier(Environment environment, String bundleId) throws IOException {
return new SignedDataVerifier(Set.of(new ByteArrayInputStream(readBytes("certs/testCA.der"))), bundleId, 1234L, environment, false);
}

public static SignedDataVerifier getSignedPayloadVerifier() throws IOException {
return getSignedPayloadVerifier(Environment.LOCAL_TESTING, "com.example");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) 2023 Apple Inc. Licensed under MIT License.

package com.apple.itunes.storekit.verification;

import com.apple.itunes.storekit.model.AppTransaction;
import com.apple.itunes.storekit.model.AutoRenewStatus;
import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.model.InAppOwnershipType;
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.model.OfferType;
import com.apple.itunes.storekit.model.TransactionReason;
import com.apple.itunes.storekit.model.Type;
import com.apple.itunes.storekit.util.TestingUtility;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.UUID;

public class XcodeSignedDataVerifierTest {

private final String XCODE_BUNDLE_ID = "com.example.naturelab.backyardbirds.example";

@Test
public void testXcodeSignedAppTransaction() throws IOException, VerificationException {
SignedDataVerifier verifier = TestingUtility.getSignedPayloadVerifier(Environment.XCODE, XCODE_BUNDLE_ID);
String encodedAppTransaction = TestingUtility.readFile("xcode/xcode-signed-app-transaction");

AppTransaction appTransaction = verifier.verifyAndDecodeAppTransaction(encodedAppTransaction);

Assertions.assertNotNull(appTransaction);
Assertions.assertNull(appTransaction.getAppAppleId());
Assertions.assertEquals(XCODE_BUNDLE_ID, appTransaction.getBundleId());
Assertions.assertEquals("1", appTransaction.getApplicationVersion());
Assertions.assertNull(appTransaction.versionExternalIdentifier());
Assertions.assertEquals(-62135769600000L, appTransaction.originalPurchaseDate());
Assertions.assertEquals("1", appTransaction.getOriginalApplicationVersion());
Assertions.assertEquals("cYUsXc53EbYc0pOeXG5d6/31LGHeVGf84sqSN0OrJi5u/j2H89WWKgS8N0hMsMlf", appTransaction.getDeviceVerification());
Assertions.assertEquals(UUID.fromString("48c8b92d-ce0d-4229-bedf-e61b4f9cfc92"), appTransaction.getDeviceVerificationNonce());
Assertions.assertNull(appTransaction.getPreorderDate());
}

@Test
public void testXcodeSignedTransaction() throws IOException, VerificationException {
SignedDataVerifier verifier = TestingUtility.getSignedPayloadVerifier(Environment.XCODE, XCODE_BUNDLE_ID);
String encodedTransactino = TestingUtility.readFile("xcode/xcode-signed-transaction");

JWSTransactionDecodedPayload transaction = verifier.verifyAndDecodeTransaction(encodedTransactino);

Assertions.assertEquals("0", transaction.getOriginalTransactionId());
Assertions.assertEquals("0", transaction.getTransactionId());
Assertions.assertEquals("0", transaction.getWebOrderLineItemId());
Assertions.assertEquals(XCODE_BUNDLE_ID, transaction.getBundleId());
Assertions.assertEquals("pass.premium", transaction.getProductId());
Assertions.assertEquals("6F3A93AB", transaction.getSubscriptionGroupIdentifier());
Assertions.assertEquals(1697679936049L, transaction.getPurchaseDate());
Assertions.assertEquals(1697679936049L, transaction.getOriginalPurchaseDate());
Assertions.assertEquals(1700358336049L, transaction.getExpiresDate());
Assertions.assertEquals(1, transaction.getQuantity());
Assertions.assertEquals(Type.AUTO_RENEWABLE_SUBSCRIPTION, transaction.getType());
Assertions.assertNull(transaction.getAppAccountToken());
Assertions.assertEquals(InAppOwnershipType.PURCHASED, transaction.getInAppOwnershipType());
Assertions.assertEquals(1697679936056L, transaction.getSignedDate());
Assertions.assertNull(transaction.getRevocationReason());
Assertions.assertNull(transaction.getRevocationDate());
Assertions.assertFalse(transaction.getIsUpgraded());
Assertions.assertEquals(OfferType.INTRODUCTORY_OFFER, transaction.getOfferType());
Assertions.assertNull(transaction.getOfferIdentifier());
Assertions.assertEquals(Environment.XCODE, transaction.getEnvironment());
Assertions.assertEquals("USA", transaction.getStorefront());
Assertions.assertEquals("143441", transaction.getStorefrontId());
Assertions.assertEquals(TransactionReason.PURCHASE, transaction.getTransactionReason());
}

@Test
public void testXcodeSignedRenewalInfo() throws IOException, VerificationException {
SignedDataVerifier verifier = TestingUtility.getSignedPayloadVerifier(Environment.XCODE, XCODE_BUNDLE_ID);
String encodedRenewalInfo = TestingUtility.readFile("xcode/xcode-signed-renewal-info");

JWSRenewalInfoDecodedPayload renewalInfo = verifier.verifyAndDecodeRenewalInfo(encodedRenewalInfo);

System.out.println(renewalInfo);
Assertions.assertNull(renewalInfo.getExpirationIntent());
Assertions.assertEquals("0", renewalInfo.getOriginalTransactionId());
Assertions.assertEquals("pass.premium", renewalInfo.getAutoRenewProductId());
Assertions.assertEquals("pass.premium", renewalInfo.getProductId());
Assertions.assertEquals(AutoRenewStatus.ON, renewalInfo.getAutoRenewStatus());
Assertions.assertNull(renewalInfo.getIsInBillingRetryPeriod());
Assertions.assertNull(renewalInfo.getPriceIncreaseStatus());
Assertions.assertNull(renewalInfo.getGracePeriodExpiresDate());
Assertions.assertNull(renewalInfo.getOfferType());
Assertions.assertNull(renewalInfo.getOfferIdentifier());
Assertions.assertEquals(1697679936711L, renewalInfo.getSignedDate());
Assertions.assertEquals(Environment.XCODE, renewalInfo.getEnvironment());
Assertions.assertEquals(1697679936049L, renewalInfo.getRecentSubscriptionStartDate());
Assertions.assertEquals(1700358336049L, renewalInfo.getRenewalDate());
}

@Test
public void testXcodeSignedAppTransactionWithProductionEnvironment() throws IOException {
SignedDataVerifier verifier = TestingUtility.getSignedPayloadVerifier(Environment.PRODUCTION, XCODE_BUNDLE_ID);
String encodedAppTransaction = TestingUtility.readFile("xcode/xcode-signed-app-transaction");
Assertions.assertThrows(VerificationException.class, () -> verifier.verifyAndDecodeAppTransaction(encodedAppTransaction));
}
}
Binary file added src/test/resources/certs/testCA.der
Binary file not shown.
1 change: 1 addition & 0 deletions src/test/resources/xcode/xcode-app-receipt-empty
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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
Loading

0 comments on commit 776812f

Please sign in to comment.