diff --git a/service/config/sample-secrets-bundle.yml b/service/config/sample-secrets-bundle.yml index 2053795db..a15b0cae1 100644 --- a/service/config/sample-secrets-bundle.yml +++ b/service/config/sample-secrets-bundle.yml @@ -8,6 +8,8 @@ braintree.privateKey: unset googlePlayBilling.credentialsJson: | { "json": true } +appleAppStore.encodedKey: unset + directoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users diff --git a/service/config/sample.yml b/service/config/sample.yml index 7ec5c1cd0..8547ab90f 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -81,6 +81,18 @@ googlePlayBilling: applicationName: test productIdToLevel: {} +appleAppStore: + env: SANDBOX + bundleId: bundle.name + appAppleId: 12345 + issuerId: abcdefg + keyId: abcdefg + encodedKey: secret://appleAppStore.encodedKey + subscriptionGroupId: example_subscriptionGroupId + productIdToLevel: {} + appleRootCerts: [] + + dynamoDbClient: region: us-west-2 # AWS Region diff --git a/service/pom.xml b/service/pom.xml index 5d4fc773e..e09fcfc9c 100644 --- a/service/pom.xml +++ b/service/pom.xml @@ -15,6 +15,7 @@ 5.1.0 1.0.392 v3-rev20240820-2.0.0 + 3.1.0 @@ -23,7 +24,18 @@ google-api-services-androidpublisher ${google-androidpublisher.version} - + + com.apple.itunes.storekit + app-store-server-library + ${storekit.version} + + + + com.squareup.okio + okio-jvm + + + io.swagger.core.v3 swagger-jaxrs2 @@ -506,7 +518,7 @@ com.apollographql.apollo3 apollo-api-jvm - 3.8.2 + 3.8.5 diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 393e465a4..80f476c99 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -15,6 +15,7 @@ import javax.validation.Valid; import javax.validation.constraints.NotNull; import org.whispersystems.textsecuregcm.attachments.TusConfiguration; import org.whispersystems.textsecuregcm.configuration.ApnConfiguration; +import org.whispersystems.textsecuregcm.configuration.AppleAppStoreConfiguration; import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory; @@ -94,6 +95,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private GooglePlayBillingConfiguration googlePlayBilling; + @NotNull + @Valid + @JsonProperty + private AppleAppStoreConfiguration appleAppStore; + @NotNull @Valid @JsonProperty @@ -368,6 +374,10 @@ public class WhisperServerConfiguration extends Configuration { return googlePlayBilling; } + public AppleAppStoreConfiguration getAppleAppStore() { + return appleAppStore; + } + public DynamoDbClientFactory getDynamoDbClientConfiguration() { return dynamoDbClient; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 75391540d..74b63be42 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -242,6 +242,7 @@ import org.whispersystems.textsecuregcm.storage.SubscriptionManager; import org.whispersystems.textsecuregcm.storage.Subscriptions; import org.whispersystems.textsecuregcm.storage.VerificationSessionManager; import org.whispersystems.textsecuregcm.storage.VerificationSessions; +import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; @@ -570,7 +571,11 @@ public class WhisperServerService extends Application productIdToLevel, + @NotNull List<@NotBlank String> appleRootCerts, + @NotNull @Valid RetryConfiguration retry) { + + public AppleAppStoreConfiguration { + if (retry == null) { + retry = new RetryConfiguration(); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java index d403f02b0..5ae90df4d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java @@ -320,6 +320,7 @@ public class OneTimeDonationController { case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId); case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId); case GOOGLE_PLAY_BILLING -> throw new BadRequestException("cannot use play billing for one-time donations"); + case APPLE_APP_STORE -> throw new BadRequestException("cannot use app store purchases for one-time donations"); }; return paymentDetailsFut.thenCompose(paymentDetails -> { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java index 4494f0bfc..9306601e7 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -262,8 +262,8 @@ public class SubscriptionController { // Today, we always choose stripe to process non-paypal payment types, however we could use braintree to process // other types (like CARD) in the future. case CARD, SEPA_DEBIT, IDEAL -> stripeManager; - case GOOGLE_PLAY_BILLING -> - throw new BadRequestException("cannot create payment methods with payment type GOOGLE_PLAY_BILLING"); + case GOOGLE_PLAY_BILLING, APPLE_APP_STORE -> + throw new BadRequestException("cannot create payment methods with payment type " + paymentMethodType); case PAYPAL -> throw new BadRequestException("The PAYPAL payment type must use create_payment_method/paypal"); case UNKNOWN -> throw new BadRequestException("Invalid payment method"); }; @@ -316,7 +316,7 @@ public class SubscriptionController { return switch (processor) { case STRIPE -> stripeManager; case BRAINTREE -> braintreeManager; - case GOOGLE_PLAY_BILLING -> throw new BadRequestException("Operation cannot be performed with the GOOGLE_PLAY_BILLING payment provider"); + case GOOGLE_PLAY_BILLING, APPLE_APP_STORE -> throw new BadRequestException("Operation cannot be performed with the " + processor + " payment provider"); }; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java index 6ca7107fc..b589fd301 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionException.java @@ -52,6 +52,10 @@ public class SubscriptionException extends Exception { public InvalidArguments(final String message, final Exception cause) { super(cause, message); } + + public InvalidArguments(final String message) { + this(message, null); + } } public static class InvalidLevel extends InvalidArguments { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java new file mode 100644 index 000000000..8c888c2e1 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java @@ -0,0 +1,319 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import com.apple.itunes.storekit.client.APIException; +import com.apple.itunes.storekit.client.AppStoreServerAPIClient; +import com.apple.itunes.storekit.model.AutoRenewStatus; +import com.apple.itunes.storekit.model.Environment; +import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.LastTransactionsItem; +import com.apple.itunes.storekit.model.Status; +import com.apple.itunes.storekit.model.StatusResponse; +import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem; +import com.apple.itunes.storekit.verification.SignedDataVerifier; +import com.apple.itunes.storekit.verification.VerificationException; +import com.google.common.annotations.VisibleForTesting; +import io.github.resilience4j.retry.Retry; +import io.micrometer.core.instrument.Metrics; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.metrics.MetricsUtil; +import org.whispersystems.textsecuregcm.storage.PaymentTime; +import org.whispersystems.textsecuregcm.storage.SubscriptionException; +import org.whispersystems.textsecuregcm.util.ExceptionUtils; + +/** + * Manages subscriptions made with the Apple App Store + *

+ * Clients create a subscription using storekit directly, and then notify us about their subscription with their + * subscription's originalTransactionId. + */ +public class AppleAppStoreManager implements SubscriptionPaymentProcessor { + + private static final Logger logger = LoggerFactory.getLogger(AppleAppStoreManager.class); + + private final AppStoreServerAPIClient apiClient; + private final SignedDataVerifier signedDataVerifier; + private final ExecutorService executor; + private final ScheduledExecutorService retryExecutor; + private final Map productIdToLevel; + + private static final Status[] EMPTY_STATUSES = new Status[0]; + + private static final String GET_SUBSCRIPTION_ERROR_COUNTER_NAME = + MetricsUtil.name(AppleAppStoreManager.class, "getSubscriptionsError"); + + private final String subscriptionGroupId; + private final Retry retry; + + + public AppleAppStoreManager( + final Environment env, + final String bundleId, + final long appAppleId, + final String issuerId, + final String keyId, + final String encodedKey, + final String subscriptionGroupId, + final Map productIdToLevel, + final List base64AppleRootCerts, + final RetryConfiguration retryConfiguration, + final ExecutorService executor, + final ScheduledExecutorService retryExecutor) { + this(new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, env), + new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, env, true), + subscriptionGroupId, productIdToLevel, retryConfiguration, executor, retryExecutor); + } + + @VisibleForTesting + AppleAppStoreManager( + final AppStoreServerAPIClient apiClient, + final SignedDataVerifier signedDataVerifier, + final String subscriptionGroupId, + final Map productIdToLevel, + final RetryConfiguration retryConfiguration, + final ExecutorService executor, + final ScheduledExecutorService retryExecutor) { + this.apiClient = apiClient; + this.signedDataVerifier = signedDataVerifier; + this.subscriptionGroupId = subscriptionGroupId; + this.productIdToLevel = productIdToLevel; + this.retry = Retry.of("appstore-retry", retryConfiguration + .toRetryConfigBuilder() + .retryOnException(AppleAppStoreManager::shouldRetry).build()); + this.executor = Objects.requireNonNull(executor); + this.retryExecutor = Objects.requireNonNull(retryExecutor); + } + + @Override + public PaymentProvider getProvider() { + return PaymentProvider.APPLE_APP_STORE; + } + + + /** + * Check if the subscription with the provided originalTransactionId is valid. + * + * @param originalTransactionId The originalTransactionId associated with the subscription + * @return A stage that completes successfully when the transaction has been validated, or fails if the token does not + * represent an active subscription. + */ + public CompletableFuture validateTransaction(final String originalTransactionId) { + return lookup(originalTransactionId).thenApplyAsync(tx -> { + if (!isSubscriptionActive(tx)) { + throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired()); + } + return getLevel(tx); + }, executor); + } + + + /** + * Cancel the subscription + *

+ * The App Store does not support backend cancellation, so this does not actually cancel, but it does verify that the + * user has no active subscriptions. End-users must cancel their subscription directly through storekit before calling + * this method. + * + * @param originalTransactionId The originalTransactionId associated with the subscription + * @return A stage that completes when the subscription has successfully been cancelled + */ + @Override + public CompletableFuture cancelAllActiveSubscriptions(String originalTransactionId) { + return lookup(originalTransactionId).thenApplyAsync(tx -> { + if (tx.signedTransaction.getStatus() != Status.EXPIRED && + tx.signedTransaction.getStatus() != Status.REVOKED && + tx.renewalInfo.getAutoRenewStatus() != AutoRenewStatus.OFF) { + throw ExceptionUtils.wrap( + new SubscriptionException.InvalidArguments("must cancel subscription with storekit before deleting")); + } + // The subscription will not auto-renew, so we can stop tracking it + return null; + }, executor); + } + + @Override + public CompletableFuture getSubscriptionInformation(final String originalTransactionId) { + return lookup(originalTransactionId).thenApplyAsync(tx -> { + + final SubscriptionStatus status = switch (tx.signedTransaction.getStatus()) { + case ACTIVE -> SubscriptionStatus.ACTIVE; + case BILLING_RETRY -> SubscriptionStatus.PAST_DUE; + case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID; + case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED; + }; + + return new SubscriptionInformation( + getSubscriptionPrice(tx), + getLevel(tx), + Instant.ofEpochMilli(tx.transaction.getOriginalPurchaseDate()), + Instant.ofEpochMilli(tx.transaction.getExpiresDate()), + isSubscriptionActive(tx), + tx.renewalInfo.getAutoRenewStatus() == AutoRenewStatus.OFF, + status, + PaymentProvider.APPLE_APP_STORE, + PaymentMethod.APPLE_APP_STORE, + false, + null); + }, executor); + } + + + @Override + public CompletableFuture getReceiptItem(String originalTransactionId) { + return lookup(originalTransactionId).thenApplyAsync(tx -> { + if (!isSubscriptionActive(tx)) { + throw ExceptionUtils.wrap(new SubscriptionException.PaymentRequired()); + } + + // A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is + // guaranteed not to change for a specific renewal purchase. + // See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid + final String itemId = tx.transaction.getWebOrderLineItemId(); + final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction.getExpiresDate())); + + return new ReceiptItem(itemId, paymentTime, getLevel(tx)); + + }, executor); + } + + private CompletableFuture lookup(final String originalTransactionId) { + return getAllSubscriptions(originalTransactionId).thenApplyAsync(statuses -> { + + final SubscriptionGroupIdentifierItem item = statuses.getData().stream() + .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst() + .orElseThrow(() -> ExceptionUtils.wrap( + new SubscriptionException.InvalidArguments("transaction did not contain a backup subscription", null))); + + final List txs = item.getLastTransactions().stream() + .map(this::decode) + .filter(decoded -> productIdToLevel.containsKey(decoded.transaction.getProductId())) + .toList(); + + if (txs.isEmpty()) { + throw ExceptionUtils.wrap( + new SubscriptionException.InvalidArguments("transactionId did not include a paid subscription", null)); + } + + if (txs.size() > 1) { + logger.warn("Multiple matching product transactions found for transactionId {}, only considering first", + originalTransactionId); + } + + if (!originalTransactionId.equals(txs.getFirst().signedTransaction.getOriginalTransactionId())) { + // Get All Subscriptions only requires that the transaction be some transaction associated with the + // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the + // SubscriptionManager. + throw ExceptionUtils.wrap( + new SubscriptionException.InvalidArguments( + "transactionId was not the transaction's originalTransactionId", null)); + } + + return txs.getFirst(); + }, executor).toCompletableFuture(); + } + + private CompletionStage getAllSubscriptions(final String originalTransactionId) { + Supplier> supplier = () -> CompletableFuture.supplyAsync(() -> { + try { + return apiClient.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES); + } catch (final APIException e) { + Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", e.getApiError().name()).increment(); + throw ExceptionUtils.wrap(switch (e.getApiError()) { + case ORIGINAL_TRANSACTION_ID_NOT_FOUND, TRANSACTION_ID_NOT_FOUND -> new SubscriptionException.NotFound(); + case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null); + case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionException.InvalidArguments(e.getApiErrorMessage()); + default -> e; + }); + } catch (final IOException e) { + Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, "reason", "io_error").increment(); + throw ExceptionUtils.wrap(e); + } + }, executor); + return retry.executeCompletionStage(retryExecutor, supplier); + } + + private static boolean shouldRetry(Throwable e) { + return ExceptionUtils.unwrap(e) instanceof APIException apiException && switch (apiException.getApiError()) { + case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true; + default -> false; + }; + } + + private record DecodedTransaction( + LastTransactionsItem signedTransaction, + JWSTransactionDecodedPayload transaction, + JWSRenewalInfoDecodedPayload renewalInfo) {} + + /** + * Verify signature and decode transaction payloads + */ + private DecodedTransaction decode(final LastTransactionsItem tx) { + try { + return new DecodedTransaction( + tx, + signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()), + signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo())); + } catch (VerificationException e) { + throw ExceptionUtils.wrap(new IOException("Failed to verify payload from App Store Server", e)); + } + } + + private SubscriptionPrice getSubscriptionPrice(final DecodedTransaction tx) { + final BigDecimal amount = new BigDecimal(tx.transaction.getPrice()).scaleByPowerOfTen(-3); + return new SubscriptionPrice( + tx.transaction.getCurrency().toUpperCase(Locale.ROOT), + SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction.getCurrency(), amount)); + } + + private long getLevel(final DecodedTransaction tx) { + final Long level = productIdToLevel.get(tx.transaction.getProductId()); + if (level == null) { + throw ExceptionUtils.wrap( + new SubscriptionException.InvalidArguments( + "Transaction for unknown productId " + tx.transaction.getProductId())); + } + return level; + } + + /** + * Return true if the subscription's entitlement can currently be granted + */ + private boolean isSubscriptionActive(final DecodedTransaction tx) { + return tx.signedTransaction.getStatus() == Status.ACTIVE + || tx.signedTransaction.getStatus() == Status.BILLING_GRACE_PERIOD; + } + + private static Set decodeRootCerts(final List rootCerts) { + return rootCerts.stream() + .map(Base64.getDecoder()::decode) + .map(ByteArrayInputStream::new) + .collect(Collectors.toSet()); + } + +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java index 438c3bd6b..5cddfee33 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java @@ -412,7 +412,6 @@ public class GooglePlayBillingManager implements SubscriptionPaymentProcessor { } } - // https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#SubscriptionState @VisibleForTesting enum SubscriptionState { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java index 35e0f387f..55ce64dff 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java @@ -23,5 +23,6 @@ public enum PaymentMethod { * An iDEAL account */ IDEAL, - GOOGLE_PLAY_BILLING + GOOGLE_PLAY_BILLING, + APPLE_APP_STORE } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java index 9112ad386..a122dfd17 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java @@ -18,7 +18,7 @@ public enum PaymentProvider { STRIPE(1), BRAINTREE(2), GOOGLE_PLAY_BILLING(3), - ; + APPLE_APP_STORE(4); private static final Map IDS_TO_PROCESSORS = new HashMap<>(); diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java new file mode 100644 index 000000000..cf7aecb0d --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java @@ -0,0 +1,241 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.subscriptions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.apple.itunes.storekit.client.APIError; +import com.apple.itunes.storekit.client.APIException; +import com.apple.itunes.storekit.client.AppStoreServerAPIClient; +import com.apple.itunes.storekit.model.AutoRenewStatus; +import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.LastTransactionsItem; +import com.apple.itunes.storekit.model.Status; +import com.apple.itunes.storekit.model.StatusResponse; +import com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem; +import com.apple.itunes.storekit.verification.SignedDataVerifier; +import com.apple.itunes.storekit.verification.VerificationException; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.whispersystems.textsecuregcm.configuration.RetryConfiguration; +import org.whispersystems.textsecuregcm.storage.SubscriptionException; +import org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil; + +class AppleAppStoreManagerTest { + + private final static long LEVEL = 123L; + private final static String ORIGINAL_TX_ID = "originalTxIdTest"; + private final static String SUBSCRIPTION_GROUP_ID = "subscriptionGroupIdTest"; + private final static String SIGNED_RENEWAL_INFO = "signedRenewalInfoTest"; + private final static String SIGNED_TX_INFO = "signedRenewalInfoTest"; + private final static String PRODUCT_ID = "productIdTest"; + private final static String WEB_ORDER_LINE_ITEM = "webOrderLineItemTest"; + + private final AppStoreServerAPIClient apiClient = mock(AppStoreServerAPIClient.class); + private final SignedDataVerifier signedDataVerifier = mock(SignedDataVerifier.class); + private ScheduledExecutorService executor; + private AppleAppStoreManager appleAppStoreManager; + + @BeforeEach + public void setup() { + reset(apiClient, signedDataVerifier); + executor = Executors.newSingleThreadScheduledExecutor(); + appleAppStoreManager = new AppleAppStoreManager(apiClient, signedDataVerifier, + SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL), new RetryConfiguration(), executor, executor); + } + + @AfterEach + public void teardown() throws InterruptedException { + executor.shutdownNow(); + executor.awaitTermination(1, TimeUnit.SECONDS); + } + + @Test + public void lookupTransaction() throws APIException, IOException, VerificationException { + mockValidSubscription(); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + + assertThat(info.active()).isTrue(); + assertThat(info.paymentProcessing()).isFalse(); + assertThat(info.level()).isEqualTo(LEVEL); + assertThat(info.cancelAtPeriodEnd()).isFalse(); + assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE); + assertThat(info.price().amount().compareTo(new BigDecimal("150"))).isEqualTo(0); // 150 cents + } + + @Test + public void validateTransaction() throws VerificationException, APIException, IOException { + mockValidSubscription(); + assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID).join()).isEqualTo(LEVEL); + } + + @Test + public void generateReceipt() throws VerificationException, APIException, IOException { + mockValidSubscription(); + final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID).join(); + assertThat(receipt.level()).isEqualTo(LEVEL); + assertThat(receipt.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO)) + .isEqualTo(Instant.EPOCH.plus(Duration.ofDays(2))); + assertThat(receipt.itemId()).isEqualTo(WEB_ORDER_LINE_ITEM); + } + + @Test + public void generateReceiptExpired() throws VerificationException, APIException, IOException { + mockSubscription(Status.EXPIRED, AutoRenewStatus.ON); + CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.PaymentRequired.class, + appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID)); + } + + @Test + public void autoRenewOff() throws VerificationException, APIException, IOException { + mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + + assertThat(info.cancelAtPeriodEnd()).isTrue(); + + assertThat(info.active()).isTrue(); + assertThat(info.paymentProcessing()).isFalse(); + assertThat(info.level()).isEqualTo(LEVEL); + assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE); + } + + @Test + public void lookupMultipleProducts() throws APIException, IOException, VerificationException { + // The lookup should select the transaction at i=1 + final List products = List.of("otherProduct1", PRODUCT_ID, "otherProduct3"); + + when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem() + .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID) + .lastTransactions(products.stream().map(product -> new LastTransactionsItem() + .originalTransactionId(ORIGINAL_TX_ID) + .status(Status.ACTIVE) + .signedRenewalInfo(SIGNED_RENEWAL_INFO) + .signedTransactionInfo(product + "_signed_tx")) + .toList())))); + when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO)) + .thenReturn(new JWSRenewalInfoDecodedPayload() + .autoRenewStatus(AutoRenewStatus.ON)); + + for (int i = 0; i < products.size(); i++) { + // Give each productId a different price, the selected transaction should have priceMillis 1000 + final long priceMillis = i * 1000L; + final String productId = products.get(i); + when(signedDataVerifier.verifyAndDecodeTransaction(productId + "_signed_tx")) + .thenReturn(new JWSTransactionDecodedPayload() + .productId(productId) + .currency("usd").price(priceMillis) + .originalPurchaseDate(Instant.EPOCH.toEpochMilli()) + .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli())); + } + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + + assertThat(info.price().amount().compareTo(new BigDecimal("100"))).isEqualTo(0); + + } + + @Test + public void retryEventuallyWorks() throws APIException, IOException, VerificationException { + // Should retry up to 3 times + when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")) + .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem() + .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID) + .addLastTransactionsItem(new LastTransactionsItem() + .originalTransactionId(ORIGINAL_TX_ID) + .status(Status.ACTIVE) + .signedRenewalInfo(SIGNED_RENEWAL_INFO) + .signedTransactionInfo(SIGNED_TX_INFO))))); + mockDecode(AutoRenewStatus.ON); + final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID).join(); + assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE); + } + + @Test + public void retryEventuallyGivesUp() throws APIException, IOException, VerificationException { + // Should retry up to 3 times + when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), "test")); + mockDecode(AutoRenewStatus.ON); + CompletableFutureTestUtil.assertFailsWithCause(APIException.class, + appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID)); + + verify(apiClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}); + + } + + @Test + public void cancelRenewalDisabled() throws APIException, VerificationException, IOException { + mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF); + assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join()); + } + + @ParameterizedTest + @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {"EXPIRED", "REVOKED"}) + public void cancelFailsForActiveSubscription(Status status) throws APIException, VerificationException, IOException { + mockSubscription(status, AutoRenewStatus.ON); + CompletableFutureTestUtil.assertFailsWithCause(SubscriptionException.InvalidArguments.class, + appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID)); + } + + @ParameterizedTest + @EnumSource(mode = EnumSource.Mode.INCLUDE, names = {"EXPIRED", "REVOKED"}) + public void cancelInactiveStatus(Status status) throws APIException, VerificationException, IOException { + mockSubscription(status, AutoRenewStatus.ON); + assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID).join()); + } + + private void mockSubscription(final Status status, final AutoRenewStatus autoRenewStatus) + throws APIException, IOException, VerificationException { + when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{})) + .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem() + .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID) + .addLastTransactionsItem(new LastTransactionsItem() + .originalTransactionId(ORIGINAL_TX_ID) + .status(status) + .signedRenewalInfo(SIGNED_RENEWAL_INFO) + .signedTransactionInfo(SIGNED_TX_INFO))))); + mockDecode(autoRenewStatus); + } + + private void mockValidSubscription() throws APIException, IOException, VerificationException { + mockSubscription(Status.ACTIVE, AutoRenewStatus.ON); + } + + private void mockDecode(final AutoRenewStatus autoRenewStatus) throws VerificationException { + when(signedDataVerifier.verifyAndDecodeTransaction(SIGNED_TX_INFO)) + .thenReturn(new JWSTransactionDecodedPayload() + .productId(PRODUCT_ID) + .currency("usd").price(1500L) // $1.50 + .originalPurchaseDate(Instant.EPOCH.toEpochMilli()) + .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli()) + .webOrderLineItemId(WEB_ORDER_LINE_ITEM)); + when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO)) + .thenReturn(new JWSRenewalInfoDecodedPayload() + .autoRenewStatus(autoRenewStatus)); + } + +} diff --git a/service/src/test/resources/config/test-secrets-bundle.yml b/service/src/test/resources/config/test-secrets-bundle.yml index b98636913..a4a873ce8 100644 --- a/service/src/test/resources/config/test-secrets-bundle.yml +++ b/service/src/test/resources/config/test-secrets-bundle.yml @@ -39,6 +39,14 @@ googlePlayBilling.credentialsJson: | TQkqmVUmgKKvCFTPWwCgeIOD -----END PRIVATE KEY-----" } +# The below private key was key generated exclusively for testing purposes. Do not use it in any other context. +appleAppStore.encodedKey: | + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgF32wH6zG3GFMBs38 + pOp712zI2NBwnccdfwxI6lidSkShRANCAATUzzM68ATLZ+TD09nT03ZyxjY1MA7H + dCuKcyQAVQo+X5lc8TpMgTWg36Kzxb4hPU1cqshIJomI0iE70eLOUe8p + -----END PRIVATE KEY----- + directoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users directoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 911b905b5..2edb863f6 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -75,6 +75,19 @@ googlePlayBilling: applicationName: test productIdToLevel: {} +appleAppStore: + env: LOCAL_TESTING + bundleId: bundle.name + appAppleId: 12345 + issuerId: abcdefg + keyId: abcdefg + encodedKey: secret://appleAppStore.encodedKey + subscriptionGroupId: example_subscriptionGroupId + productIdToLevel: {} + appleRootCerts: + # An apple root cert https://www.apple.com/certificateauthority/ + - MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh + dynamoDbClient: type: local