Add AppleAppStoreManager

This commit is contained in:
ravi-signal 2024-10-04 15:55:07 -05:00 committed by GitHub
parent ca2845bcb0
commit 02ff3f2ff4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 690 additions and 9 deletions

View File

@ -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

View File

@ -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

View File

@ -15,6 +15,7 @@
<java-uuid-generator.version>5.1.0</java-uuid-generator.version>
<sqlite4java.version>1.0.392</sqlite4java.version>
<google-androidpublisher.version>v3-rev20240820-2.0.0</google-androidpublisher.version>
<storekit.version>3.1.0</storekit.version>
</properties>
<dependencies>
@ -23,7 +24,18 @@
<artifactId>google-api-services-androidpublisher</artifactId>
<version>${google-androidpublisher.version}</version>
</dependency>
<dependency>
<groupId>com.apple.itunes.storekit</groupId>
<artifactId>app-store-server-library</artifactId>
<version>${storekit.version}</version>
<exclusions>
<!-- conflicts with okio-jvm from apollo-api-jvm -->
<exclusion>
<groupId>com.squareup.okio</groupId>
<artifactId>okio-jvm</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-jaxrs2</artifactId>
@ -506,7 +518,7 @@
<dependency>
<groupId>com.apollographql.apollo3</groupId>
<artifactId>apollo-api-jvm</artifactId>
<version>3.8.2</version>
<version>3.8.5</version>
<exclusions>
<exclusion>

View File

@ -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;
}

View File

@ -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<WhisperServerConfiguration
.virtualExecutorService(name(getClass(), "keyTransparency-%d"));
ExecutorService googlePlayBillingExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "googlePlayBilling-%d"));
ExecutorService appleAppStoreExecutor = environment.lifecycle()
.virtualExecutorService(name(getClass(), "appleAppStore-%d"));
ScheduledExecutorService appleAppStoreRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "appleAppStoreRetry-%d")).threads(1).build();
ScheduledExecutorService subscriptionProcessorRetryExecutor = environment.lifecycle()
.scheduledExecutorService(name(getClass(), "subscriptionProcessorRetry-%d")).threads(1).build();
ScheduledExecutorService cloudflareTurnRetryExecutor = environment.lifecycle()
@ -738,6 +743,13 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
config.getGooglePlayBilling().applicationName(),
config.getGooglePlayBilling().productIdToLevel(),
googlePlayBillingExecutor);
AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager(
config.getAppleAppStore().env(), config.getAppleAppStore().bundleId(), config.getAppleAppStore().appAppleId(),
config.getAppleAppStore().issuerId(), config.getAppleAppStore().keyId(),
config.getAppleAppStore().encodedKey().value(), config.getAppleAppStore().subscriptionGroupId(),
config.getAppleAppStore().productIdToLevel(),
config.getAppleAppStore().appleRootCerts(),
config.getAppleAppStore().retry(), appleAppStoreExecutor, appleAppStoreRetryExecutor);
environment.lifecycle().manage(apnSender);
environment.lifecycle().manage(pushNotificationScheduler);
@ -1127,7 +1139,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
);
if (config.getSubscription() != null && config.getOneTimeDonations() != null) {
SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,
List.of(stripeManager, braintreeManager, googlePlayBillingManager),
List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),
zkReceiptOperations, issuedReceiptsManager);
commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),
subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager,

View File

@ -0,0 +1,47 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.configuration;
import com.apple.itunes.storekit.model.Environment;
import java.util.List;
import java.util.Map;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import org.whispersystems.textsecuregcm.configuration.secrets.SecretString;
/**
* @param env The ios environment to use, typically SANDBOX or PRODUCTION
* @param bundleId The bundleId of the app
* @param appAppleId The integer id of the app
* @param issuerId The issuerId for the keys:
* https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
* @param keyId The keyId for encodedKey
* @param encodedKey A private key with the "In-App Purchase" key type
* @param subscriptionGroupId The subscription group for in-app purchases
* @param productIdToLevel A map of productIds offered in the product catalog to their corresponding numeric
* subscription levels
* @param appleRootCerts Apple root certificates to verify signed API responses, encoded as base64 strings:
* https://www.apple.com/certificateauthority/
*/
public record AppleAppStoreConfiguration(
@NotNull Environment env,
@NotBlank String bundleId,
@NotNull Long appAppleId,
@NotBlank String issuerId,
@NotBlank String keyId,
@NotNull SecretString encodedKey,
@NotBlank String subscriptionGroupId,
@NotNull Map<String, Long> productIdToLevel,
@NotNull List<@NotBlank String> appleRootCerts,
@NotNull @Valid RetryConfiguration retry) {
public AppleAppStoreConfiguration {
if (retry == null) {
retry = new RetryConfiguration();
}
}
}

View File

@ -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 -> {

View File

@ -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");
};
}

View File

@ -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 {

View File

@ -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
* <p>
* Clients create a subscription using storekit directly, and then notify us about their subscription with their
* subscription's <a
* href="https://developer.apple.com/documentation/appstoreserverapi/originaltransactionid">originalTransactionId</a>.
*/
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<String, Long> 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<String, Long> productIdToLevel,
final List<String> 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<String, Long> 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<Long> 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
* <p>
* 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<Void> 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<SubscriptionInformation> 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<ReceiptItem> 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<DecodedTransaction> 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<DecodedTransaction> 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<StatusResponse> getAllSubscriptions(final String originalTransactionId) {
Supplier<CompletionStage<StatusResponse>> 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<InputStream> decodeRootCerts(final List<String> rootCerts) {
return rootCerts.stream()
.map(Base64.getDecoder()::decode)
.map(ByteArrayInputStream::new)
.collect(Collectors.toSet());
}
}

View File

@ -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 {

View File

@ -23,5 +23,6 @@ public enum PaymentMethod {
* An iDEAL account
*/
IDEAL,
GOOGLE_PLAY_BILLING
GOOGLE_PLAY_BILLING,
APPLE_APP_STORE
}

View File

@ -18,7 +18,7 @@ public enum PaymentProvider {
STRIPE(1),
BRAINTREE(2),
GOOGLE_PLAY_BILLING(3),
;
APPLE_APP_STORE(4);
private static final Map<Integer, PaymentProvider> IDS_TO_PROCESSORS = new HashMap<>();

View File

@ -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<String> 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));
}
}

View File

@ -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

View File

@ -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