From d6b80dfd16224d258739510fc830aeb562552855 Mon Sep 17 00:00:00 2001 From: Ravi Khadiwala Date: Mon, 3 Aug 2026 10:11:01 -0500 Subject: [PATCH] Add LoginPurchaseController --- service/config/sample.yml | 5 + .../WhisperServerConfiguration.java | 10 + .../textsecuregcm/WhisperServerService.java | 21 +- .../LoginPurchaseConfiguration.java | 20 ++ .../dynamic/DynamicConfiguration.java | 8 + .../DynamicLoginPurchaseConfiguration.java | 8 + .../controllers/LoginPurchaseController.java | 127 +++++++++++ .../controllers/SubscriptionController.java | 17 ++ .../grpc/ProductConfigurationGrpcService.java | 13 ++ .../OneTimePaymentProcessor.java | 1 - .../signal/chat/product_configuration.proto | 12 + .../LoginPurchaseControllerTest.java | 209 ++++++++++++++++++ .../SubscriptionControllerTest.java | 9 +- .../ProductConfigurationGrpcServiceTest.java | 10 +- service/src/test/resources/config/test.yml | 5 + 15 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/LoginPurchaseConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicLoginPurchaseConfiguration.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseController.java create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseControllerTest.java diff --git a/service/config/sample.yml b/service/config/sample.yml index 9e6216f8e..a0fcf8a00 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -385,6 +385,11 @@ subscription: # configuration for Stripe subscriptions STRIPE: price_example # stripe Price ID BRAINTREE: plan_example # braintree Plan ID +loginPurchase: + level: 300 + playProductId: login_example + appStoreProductId: login_example + oneTimeDonations: sepaMaximumEuros: '10000' boost: diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index 2dbc18adb..84b202bd1 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -44,6 +44,7 @@ import org.whispersystems.textsecuregcm.configuration.HlrLookupConfiguration; import org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration; import org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration; +import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration; import org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; @@ -266,6 +267,11 @@ public class WhisperServerConfiguration extends Configuration { @NotNull private OneTimeDonationConfiguration oneTimeDonations; + @Valid + @JsonProperty + @NotNull + private LoginPurchaseConfiguration loginPurchase; + @Valid @JsonProperty @NotNull @@ -532,6 +538,10 @@ public class WhisperServerConfiguration extends Configuration { return oneTimeDonations; } + public LoginPurchaseConfiguration getLoginPurchase() { + return loginPurchase; + } + public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() { return pagedSingleUseKEMPreKeyStore; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 60615e0f2..d6ab833a3 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -130,6 +130,7 @@ import org.whispersystems.textsecuregcm.controllers.DonationController; import org.whispersystems.textsecuregcm.controllers.KeepAliveController; import org.whispersystems.textsecuregcm.controllers.KeyTransparencyController; import org.whispersystems.textsecuregcm.controllers.KeysController; +import org.whispersystems.textsecuregcm.controllers.LoginPurchaseController; import org.whispersystems.textsecuregcm.controllers.MessageController; import org.whispersystems.textsecuregcm.controllers.OneTimeDonationController; import org.whispersystems.textsecuregcm.controllers.PaymentsController; @@ -300,7 +301,9 @@ import org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager; import org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; import org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager; +import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager; import org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator; +import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; import org.whispersystems.textsecuregcm.subscriptions.StripeManager; import org.whispersystems.textsecuregcm.telephony.CarrierDataProvider; import org.whispersystems.textsecuregcm.telephony.hlrlookup.HlrLookupCarrierDataProvider; @@ -987,6 +990,15 @@ public class WhisperServerService extends Application spamFilters = ServiceLoader.load(SpamFilter.class) .stream() .map(ServiceLoader.Provider::get) @@ -1109,7 +1121,8 @@ public class WhisperServerService extends Application getExperimentEnrollmentConfiguration( final String experimentName) { return Optional.ofNullable(experiments.get(experimentName)); @@ -154,4 +158,8 @@ public class DynamicConfiguration { public DynamicTurnConfiguration getTurnConfiguration() { return turn; } + + public DynamicLoginPurchaseConfiguration getLoginPurchaseConfiguration() { + return loginPurchase; + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicLoginPurchaseConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicLoginPurchaseConfiguration.java new file mode 100644 index 000000000..e79b7aadf --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicLoginPurchaseConfiguration.java @@ -0,0 +1,8 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.configuration.dynamic; + +/// @param enabled whether clients may exchange a one-time purchase for a Signal Login receipt credential +public record DynamicLoginPurchaseConfiguration(boolean enabled) {} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseController.java new file mode 100644 index 000000000..21deffda3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseController.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.whispersystems.textsecuregcm.controllers; + +import io.dropwizard.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.util.Optional; +import org.glassfish.jersey.server.ManagedAsync; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager; +import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException; + +@Path("/v1/login-purchase") +@io.swagger.v3.oas.annotations.tags.Tag(name = "LoginPurchase") +public class LoginPurchaseController { + + private final LoginPurchaseManager loginPurchaseManager; + private final DynamicConfigurationManager dynamicConfigurationManager; + + public LoginPurchaseController( + final LoginPurchaseManager loginPurchaseManager, + final DynamicConfigurationManager dynamicConfigurationManager) { + this.loginPurchaseManager = loginPurchaseManager; + this.dynamicConfigurationManager = dynamicConfigurationManager; + } + + public record CreateLoginReceiptCredentialRequest( + @NotNull String purchaseIdentifier, + @NotNull byte[] receiptCredentialRequest, + @NotNull PaymentProvider paymentProvider) { + } + + public record CreateLoginReceiptCredentialResponse(byte[] receiptCredentialResponse) { + } + + @POST + @Path("/receipt_credentials") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Create a receipt credential for a completed one-time login purchase", + description = """ + Verify a completed one-time purchase with the payment provider and issue a receipt credential that can be + redeemed for a login. + + This endpoint must be called on an unauthenticated connection. Retries for the same purchaseIdentifier MUST + use the same receiptCredentialRequest. + """) + @ApiResponse(responseCode = "200", description = "Successfully created receipt", + content = @Content(schema = @Schema(implementation = CreateLoginReceiptCredentialResponse.class))) + @ApiResponse(responseCode = "204", description = "The purchase is still pending with the payment provider. The client may retry later.") + @ApiResponse(responseCode = "400", description = """ + Invalid request: malformed request, failed zkgroup verification, unsupported paymentProvider, the provided + purchase identifier not for a Signal Login, or login purchases are not currently enabled. + """) + @ApiResponse(responseCode = "402", description = "The purchase did not complete successfully. The body may include ChargeFailure details.", + content = @Content(schema = @Schema( + nullable = true, + implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class))) + @ApiResponse(responseCode = "403", description = "The request was made on an authenticated channel") + @ApiResponse(responseCode = "404", description = "The payment provider has no purchase with the provided purchaseIdentifier") + @ApiResponse(responseCode = "409", description = "The purchase was already redeemed for a receipt credential, but with a different receipt credential request") + @ApiResponse(responseCode = "429", description = "Too many attempts", headers = @Header( + name = "Retry-After", + description = "If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed")) + @ManagedAsync + public Response createLoginReceiptCredential( + @Auth final Optional authenticatedAccount, + @NotNull @Valid final CreateLoginReceiptCredentialRequest request) + throws IOException, SubscriptionPaymentRequiredException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException, SubscriptionReceiptAlreadyRedeemedException { + + if (!dynamicConfigurationManager.getConfiguration().getLoginPurchaseConfiguration().enabled()) { + throw new BadRequestException("login purchases are not enabled"); + } + + if (authenticatedAccount.isPresent()) { + throw new ForbiddenException("must not use authenticated connection for login purchase operations"); + } + + final ReceiptCredentialRequest receiptCredentialRequest; + try { + receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest); + } catch (final InvalidInputException e) { + throw new BadRequestException("invalid receipt credential request", e); + } + + try { + final ReceiptCredentialResponse receiptCredentialResponse = loginPurchaseManager.generateReceipt( + request.paymentProvider, request.purchaseIdentifier, receiptCredentialRequest); + return Response.ok( + new CreateLoginReceiptCredentialResponse(receiptCredentialResponse.serialize())) + .build(); + } catch (SubscriptionReceiptRequestedForOpenPaymentException e) { + return Response.noContent().build(); + } catch (VerificationFailedException e) { + throw new BadRequestException("receipt credential request failed verification", e); + } + } +} 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 7bf1d22a4..3c0fc774b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java @@ -66,6 +66,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.auth.DonationPermitHeader; import org.whispersystems.textsecuregcm.badges.BadgeTranslator; +import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration; @@ -106,6 +107,7 @@ public class SubscriptionController { private final Clock clock; private final SubscriptionConfiguration subscriptionConfiguration; private final OneTimeDonationConfiguration oneTimeDonationConfiguration; + private final LoginPurchaseConfiguration loginPurchaseConfiguration; private final SubscriptionManager subscriptionManager; private final StripeManager stripeManager; private final BraintreeManager braintreeManager; @@ -124,6 +126,7 @@ public class SubscriptionController { Clock clock, SubscriptionConfiguration subscriptionConfiguration, OneTimeDonationConfiguration oneTimeDonationConfiguration, + LoginPurchaseConfiguration loginPurchaseConfiguration, SubscriptionManager subscriptionManager, StripeManager stripeManager, BraintreeManager braintreeManager, @@ -137,6 +140,7 @@ public class SubscriptionController { this.clock = Objects.requireNonNull(clock); this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration); this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration); + this.loginPurchaseConfiguration = Objects.requireNonNull(loginPurchaseConfiguration); this.stripeManager = Objects.requireNonNull(stripeManager); this.braintreeManager = Objects.requireNonNull(braintreeManager); this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager); @@ -167,6 +171,8 @@ public class SubscriptionController { buildDonationLevelsConfiguration(subscriptionConfiguration, oneTimeDonationConfiguration, badgeTranslator, acceptableLanguages), new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()), + new LoginConfiguration(loginPurchaseConfiguration.level(), loginPurchaseConfiguration.playProductId(), + loginPurchaseConfiguration.appStoreProductId()), oneTimeDonationConfiguration.sepaMaximumEuros()); } @@ -532,9 +538,20 @@ public class SubscriptionController { Map levels, @Schema(description = "Backup specific configuration") BackupConfiguration backup, + @Schema(description = "Signal Login specific configuration") + LoginConfiguration login, @Schema(description = "The maximum value of a one-time donation SEPA transaction") BigDecimal sepaMaximumEuros) {} + @Schema(description = "Configuration for one-time Signal Login purchases") + public record LoginConfiguration( + @Schema(description = "The receipt level associated with a Signal Login purchase") + long level, + @Schema(description = "The play billing productID associated with a Signal Login purchase") + String playProductId, + @Schema(description = "The App Store productID associated with a Signal Login purchase") + String appStoreProductId) {} + public record BackupConfiguration( @Schema(description = "A map of numeric backup level IDs to level-specific backup configuration") Map levels, diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java index fa4a03fb2..74b7c905e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcService.java @@ -12,7 +12,9 @@ import org.signal.chat.purchase.CurrencyConfiguration; import org.signal.chat.purchase.GetConfigurationRequest; import org.signal.chat.purchase.GetConfigurationResponse; import org.signal.chat.purchase.LevelConfiguration; +import org.signal.chat.purchase.LoginConfiguration; import org.signal.chat.purchase.SimpleProductConfigurationGrpc; +import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor; @@ -24,6 +26,7 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG public ProductConfigurationGrpcService( final SubscriptionConfiguration subscriptionConfiguration, final OneTimeDonationConfiguration oneTimeDonationConfiguration, + final LoginPurchaseConfiguration loginPurchaseConfiguration, List paymentProcessors, final long backupMediaStorageAllowanceBytes) { this.configurationResponse = GetConfigurationResponse.newBuilder() @@ -31,6 +34,7 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG .setSepaMaximumEuros(oneTimeDonationConfiguration.sepaMaximumEuros().toString()) .putAllCurrencies(buildCurrencyConfigurations(subscriptionConfiguration, oneTimeDonationConfiguration, paymentProcessors)) .putAllBadgeLevels(buildLevelConfigurations(subscriptionConfiguration, oneTimeDonationConfiguration)) + .setLogin(buildLoginConfiguration(loginPurchaseConfiguration)) .build(); } @@ -85,6 +89,15 @@ public class ProductConfigurationGrpcService extends SimpleProductConfigurationG .build(); } + private static LoginConfiguration buildLoginConfiguration( + final LoginPurchaseConfiguration loginPurchaseConfiguration) { + return LoginConfiguration.newBuilder() + .setLevel(loginPurchaseConfiguration.level()) + .setPlayProductId(loginPurchaseConfiguration.playProductId()) + .setAppStoreProductId(loginPurchaseConfiguration.appStoreProductId()) + .build(); + } + private static CurrencyConfiguration toProtoCurrencyConfiguration( final String currency, final org.whispersystems.textsecuregcm.subscriptions.CurrencyConfiguration config) { diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/OneTimePaymentProcessor.java b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/OneTimePaymentProcessor.java index dbdd8fc25..e62d21a5e 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/OneTimePaymentProcessor.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/OneTimePaymentProcessor.java @@ -4,7 +4,6 @@ */ package org.whispersystems.textsecuregcm.subscriptions; -import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; import java.io.IOException; import java.util.Optional; import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; diff --git a/service/src/main/proto/org/signal/chat/product_configuration.proto b/service/src/main/proto/org/signal/chat/product_configuration.proto index fe283e791..c9e894e66 100644 --- a/service/src/main/proto/org/signal/chat/product_configuration.proto +++ b/service/src/main/proto/org/signal/chat/product_configuration.proto @@ -33,6 +33,8 @@ message GetConfigurationResponse { BackupConfiguration backup = 3; // Maximum value of a one-time SEPA donation string sepa_maximum_euros = 4; + // Configuration for one-time Signal Login purchases + LoginConfiguration login = 5; } message AmountList { @@ -81,3 +83,13 @@ message BackupConfiguration { // The number of days of media a free tier backup user gets uint64 free_tier_media_days = 2; } + +// Configuration for one-time Signal Login purchases +message LoginConfiguration { + // The receipt level associated with a Signal Login purchase + uint64 level = 1; + // The play billing productID associated with a Signal Login purchase + string play_product_id = 2; + // The App Store productID associated with a Signal Login purchase + string app_store_product_id = 3; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseControllerTest.java new file mode 100644 index 000000000..453532fe5 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/LoginPurchaseControllerTest.java @@ -0,0 +1,209 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.controllers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.dropwizard.auth.AuthValueFactoryProvider; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Response; +import java.util.stream.Stream; +import org.glassfish.jersey.server.ServerProperties; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.signal.libsignal.zkgroup.InvalidInputException; +import org.signal.libsignal.zkgroup.ServerSecretParams; +import org.signal.libsignal.zkgroup.VerificationFailedException; +import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext; +import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse; +import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; +import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; +import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration; +import org.whispersystems.textsecuregcm.configuration.dynamic.DynamicLoginPurchaseConfiguration; +import org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper; +import org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper; +import org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager; +import org.whispersystems.textsecuregcm.subscriptions.ChargeFailure; +import org.whispersystems.textsecuregcm.subscriptions.LoginPurchaseManager; +import org.whispersystems.textsecuregcm.subscriptions.PaymentProvider; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptAlreadyRedeemedException; +import org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.SystemMapper; +import org.whispersystems.textsecuregcm.util.TestRandomUtil; + +@ExtendWith(DropwizardExtensionsSupport.class) +class LoginPurchaseControllerTest { + + private static final String PURCHASE_ID = "purchaseId"; + + private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate(); + private static final ClientZkReceiptOperations CLIENT_ZK_OPS = + new ClientZkReceiptOperations(SERVER_SECRET_PARAMS.getPublicParams()); + private static final ServerZkReceiptOperations SERVER_ZK_OPS = + new ServerZkReceiptOperations(SERVER_SECRET_PARAMS); + + private static final LoginPurchaseManager LOGIN_PURCHASE_MANAGER = mock(LoginPurchaseManager.class); + + private static final DynamicLoginPurchaseConfiguration ENABLED = new DynamicLoginPurchaseConfiguration(true); + private static final DynamicLoginPurchaseConfiguration DISABLED = new DynamicLoginPurchaseConfiguration(false); + + @SuppressWarnings("unchecked") + private static final DynamicConfigurationManager DYNAMIC_CONFIGURATION_MANAGER = + mock(DynamicConfigurationManager.class); + private static final DynamicConfiguration DYNAMIC_CONFIGURATION = mock(DynamicConfiguration.class); + + private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder() + .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE) + .addProvider(AuthHelper.getAuthFilter()) + .addProvider(CompletionExceptionMapper.class) + .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class)) + .addProvider(SubscriptionExceptionMapper.class) + .addProvider(RateLimitExceededExceptionMapper.class) + .setMapper(SystemMapper.jsonMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new LoginPurchaseController(LOGIN_PURCHASE_MANAGER, DYNAMIC_CONFIGURATION_MANAGER)) + .build(); + + private ReceiptCredentialRequestContext receiptCredentialRequestContext; + + @BeforeEach + void setUp() throws InvalidInputException, VerificationFailedException { + reset(LOGIN_PURCHASE_MANAGER, DYNAMIC_CONFIGURATION_MANAGER, DYNAMIC_CONFIGURATION); + + when(DYNAMIC_CONFIGURATION_MANAGER.getConfiguration()).thenReturn(DYNAMIC_CONFIGURATION); + when(DYNAMIC_CONFIGURATION.getLoginPurchaseConfiguration()).thenReturn(ENABLED); + + receiptCredentialRequestContext = CLIENT_ZK_OPS.createReceiptCredentialRequestContext( + new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE))); + } + + @Test + void createReceiptCredential() throws Exception { + final ReceiptCredentialResponse receiptCredentialResponse = + SERVER_ZK_OPS.issueReceiptCredential(receiptCredentialRequestContext.getRequest(), 0L, 200L); + + when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())).thenReturn(receiptCredentialResponse); + + try (final Response response = RESOURCE_EXTENSION + .target("/v1/login-purchase/receipt_credentials") + .request() + .post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest( + PURCHASE_ID, + receiptCredentialRequestContext.getRequest().serialize(), + PaymentProvider.APPLE_APP_STORE)))) { + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.readEntity(LoginPurchaseController.CreateLoginReceiptCredentialResponse.class) + .receiptCredentialResponse()).isEqualTo(receiptCredentialResponse.serialize()); + } + + verify(LOGIN_PURCHASE_MANAGER).generateReceipt( + eq(PaymentProvider.APPLE_APP_STORE), + eq(PURCHASE_ID), + any(ReceiptCredentialRequest.class)); + } + + @Test + void createReceiptCredentialAuthenticated() { + try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest( + PURCHASE_ID, + receiptCredentialRequestContext.getRequest().serialize(), + PaymentProvider.APPLE_APP_STORE)))) { + + assertThat(response.getStatus()).isEqualTo(403); + } + + verifyNoInteractions(LOGIN_PURCHASE_MANAGER); + } + + @Test + void createReceiptCredentialNotEnabled() { + when(DYNAMIC_CONFIGURATION.getLoginPurchaseConfiguration()).thenReturn(DISABLED); + + try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials") + .request() + .post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest( + PURCHASE_ID, + receiptCredentialRequestContext.getRequest().serialize(), + PaymentProvider.APPLE_APP_STORE)))) { + + assertThat(response.getStatus()).isEqualTo(400); + } + + verifyNoInteractions(LOGIN_PURCHASE_MANAGER); + } + + static Stream createReceiptCredentialErrors() { + return Stream.of( + Arguments.of(new SubscriptionReceiptRequestedForOpenPaymentException(), 204), + Arguments.of(new SubscriptionPaymentRequiredException(), 402), + Arguments.of(new SubscriptionNotFoundException(), 404), + Arguments.of(new SubscriptionInvalidArgumentsException("test"), 400), + Arguments.of(new SubscriptionReceiptAlreadyRedeemedException(), 409), + Arguments.of(new VerificationFailedException(), 400), + Arguments.of(new RateLimitExceededException(null), 429)); + } + + @ParameterizedTest + @MethodSource + void createReceiptCredentialErrors(final Exception exception, final int expectedStatus) throws Exception { + when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())).thenThrow(exception); + + try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials") + .request() + .post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest( + PURCHASE_ID, + receiptCredentialRequestContext.getRequest().serialize(), + PaymentProvider.APPLE_APP_STORE)))) { + + assertThat(response.getStatus()).isEqualTo(expectedStatus); + } + } + + @Test + void createReceiptCredentialPaymentRequiredWithChargeFailure() throws Exception { + final ChargeFailure chargeFailure = + new ChargeFailure("generic_decline", "some failure message", null, null, null); + when(LOGIN_PURCHASE_MANAGER.generateReceipt(any(), any(), any())) + .thenThrow(new SubscriptionChargeFailurePaymentRequiredException(PaymentProvider.APPLE_APP_STORE, chargeFailure)); + + try (final Response response = RESOURCE_EXTENSION.target("/v1/login-purchase/receipt_credentials") + .request() + .post(Entity.json(new LoginPurchaseController.CreateLoginReceiptCredentialRequest( + PURCHASE_ID, + receiptCredentialRequestContext.getRequest().serialize(), + PaymentProvider.APPLE_APP_STORE)))) { + final SubscriptionExceptionMapper.ChargeFailureResponse failureResponse = + response.readEntity(SubscriptionExceptionMapper.ChargeFailureResponse.class); + assertThat(failureResponse.chargeFailure()).isEqualTo(chargeFailure); + } + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java index 565f09734..0da8679fc 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java @@ -60,6 +60,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; import org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations; import org.whispersystems.textsecuregcm.auth.AuthenticatedDevice; import org.whispersystems.textsecuregcm.badges.BadgeTranslator; +import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetBankMandateResponse; import org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse; @@ -109,8 +110,10 @@ class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest { private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class); private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class); private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class); + private static final LoginPurchaseConfiguration LOGIN_PURCHASE_CONFIG = + new LoginPurchaseConfiguration(300L, "testLoginPlayProductId", "testLoginAppStoreProductId"); private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK, - SUBSCRIPTION_CONFIG, ONETIME_CONFIG, + SUBSCRIPTION_CONFIG, ONETIME_CONFIG, LOGIN_PURCHASE_CONFIG, new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER), ZK_OPS, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER, BADGE_TRANSLATOR, BANK_MANDATE_TRANSLATOR, DONATION_PERMITS_MANAGER, MAX_TOTAL_BACKUP_MEDIA_BYTES); @@ -1074,6 +1077,10 @@ class SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest { }); assertThat(response.backup().freeTierMediaDays()).isEqualTo(30); + assertThat(response.login().level()).isEqualTo(LOGIN_PURCHASE_CONFIG.level()); + assertThat(response.login().playProductId()).isEqualTo(LOGIN_PURCHASE_CONFIG.playProductId()); + assertThat(response.login().appStoreProductId()).isEqualTo(LOGIN_PURCHASE_CONFIG.appStoreProductId()); + // check the badge vs purchasable badge fields // subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration` Map genericResponse = RESOURCE_EXTENSION.target("/v1/subscription/configuration") diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcServiceTest.java index f0e722492..ab8f2ba80 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcServiceTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProductConfigurationGrpcServiceTest.java @@ -22,6 +22,7 @@ import org.signal.chat.purchase.GetConfigurationRequest; import org.signal.chat.purchase.GetConfigurationResponse; import org.signal.chat.purchase.PaymentMethod; import org.signal.chat.purchase.ProductConfigurationGrpc; +import org.whispersystems.textsecuregcm.configuration.LoginPurchaseConfiguration; import org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration; import org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration; import org.whispersystems.textsecuregcm.subscriptions.BraintreeManager; @@ -37,6 +38,9 @@ public class ProductConfigurationGrpcServiceTest extends private final OneTimeDonationConfiguration oneTimeDonationConfiguration = SubscriptionConfigTestHelper.getOneTimeConfig(); + private static final LoginPurchaseConfiguration LOGIN_PURCHASE_CONFIGURATION = + new LoginPurchaseConfiguration(300L, "testLoginPlayProductId", "testLoginAppStoreProductId"); + @Mock private StripeManager stripeManager; @@ -65,7 +69,7 @@ public class ProductConfigurationGrpcServiceTest extends return new ProductConfigurationGrpcService(subscriptionConfiguration, oneTimeDonationConfiguration, - List.of(stripeManager, braintreeManager), 1234L); + LOGIN_PURCHASE_CONFIGURATION, List.of(stripeManager, braintreeManager), 1234L); } @Test @@ -127,6 +131,10 @@ public class ProductConfigurationGrpcServiceTest extends assertTrue(configuration.getBadgeLevelsOrThrow(1L).getBadgeDurationSeconds() > 0); assertEquals("GIFT", configuration.getBadgeLevelsOrThrow(100L).getBadgeId()); assertTrue(configuration.getBadgeLevelsOrThrow(100L).getBadgeDurationSeconds() > 0); + + assertEquals(LOGIN_PURCHASE_CONFIGURATION.level(), configuration.getLogin().getLevel()); + assertEquals(LOGIN_PURCHASE_CONFIGURATION.playProductId(), configuration.getLogin().getPlayProductId()); + assertEquals(LOGIN_PURCHASE_CONFIGURATION.appStoreProductId(), configuration.getLogin().getAppStoreProductId()); } diff --git a/service/src/test/resources/config/test.yml b/service/src/test/resources/config/test.yml index 6f013eb88..e39e42cdb 100644 --- a/service/src/test/resources/config/test.yml +++ b/service/src/test/resources/config/test.yml @@ -397,6 +397,11 @@ subscription: # configuration for Stripe subscriptions STRIPE: price_example # stripe Price ID BRAINTREE: plan_example # braintree Plan ID +loginPurchase: + level: 300 + playProductId: login_example + appStoreProductId: login_example + oneTimeDonations: sepaMaximumEuros: '10000' boost: