From 13a545834438c9ee91f4e58321adcf788d94397f Mon Sep 17 00:00:00 2001 From: Ameya Lokare Date: Mon, 1 Jun 2026 13:33:45 -0700 Subject: [PATCH] Port ChallengeController to gRPC --- .../textsecuregcm/WhisperServerService.java | 4 +- .../textsecuregcm/captcha/CaptchaChecker.java | 3 +- .../textsecuregcm/captcha/CaptchaClient.java | 5 +- .../controllers/ChallengeController.java | 4 +- .../grpc/ChallengeGrpcService.java | 56 ++++++ .../limits/RateLimitChallengeManager.java | 6 +- .../spam/ChallengeConstraintChecker.java | 28 ++- .../proto/org/signal/chat/challenge.proto | 52 ++++++ .../controllers/ChallengeControllerTest.java | 8 +- .../grpc/ChallengeGrpcServiceTest.java | 160 ++++++++++++++++++ 10 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcService.java create mode 100644 service/src/main/proto/org/signal/chat/challenge.proto create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcServiceTest.java diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 011ee9aea..925cbd6f7 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -152,6 +152,7 @@ import org.whispersystems.textsecuregcm.grpc.AttachmentsGrpcService; import org.whispersystems.textsecuregcm.grpc.BackupsAnonymousGrpcService; import org.whispersystems.textsecuregcm.grpc.BackupsGrpcService; import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService; +import org.whispersystems.textsecuregcm.grpc.ChallengeGrpcService; import org.whispersystems.textsecuregcm.grpc.DevicesGrpcService; import org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor; import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor; @@ -995,7 +996,8 @@ public class WhisperServerService extends Application ServerInterceptors.intercept(bindableService, // Note: interceptors run in the reverse order they are added; the remote deprecation filter // depends on the user-agent context so it has to come first here! diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java index fc8825bf6..17430034b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java @@ -18,6 +18,7 @@ import java.util.UUID; import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.annotation.Nullable; public class CaptchaChecker { private static final Logger logger = LoggerFactory.getLogger(CaptchaChecker.class); @@ -60,7 +61,7 @@ public class CaptchaChecker { final Action expectedAction, final String input, final String ip, - final String userAgent) throws IOException { + @Nullable final String userAgent) throws IOException { final String[] parts = input.split("\\" + SEPARATOR, 4); // we allow missing actions, if we're missing 1 part, assume it's the action diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java index c7b99f048..d12a4f706 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java @@ -5,6 +5,7 @@ package org.whispersystems.textsecuregcm.captcha; +import javax.annotation.Nullable; import java.io.IOException; import java.util.Optional; import java.util.Set; @@ -42,7 +43,7 @@ public interface CaptchaClient { final Action action, final String token, final String ip, - final String userAgent) throws IOException; + @Nullable final String userAgent) throws IOException; static CaptchaClient noop() { return new CaptchaClient() { @@ -58,7 +59,7 @@ public interface CaptchaClient { @Override public AssessmentResult verify(final Optional maybeAci, final String siteKey, final Action action, final String token, final String ip, - final String userAgent) throws IOException { + @Nullable final String userAgent) throws IOException { return AssessmentResult.alwaysValid(); } }; diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java index e79682742..4d184806d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java @@ -97,7 +97,7 @@ public class ChallengeController { Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)); - final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints( + final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsHttp( requestContext, account); try { if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) { @@ -185,7 +185,7 @@ public class ChallengeController { final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier()) .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)); - final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account); + final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsHttp(requestContext, account); if (!constraints.pushPermitted()) { return Response.status(429).build(); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcService.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcService.java new file mode 100644 index 000000000..bbd7cd99a --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcService.java @@ -0,0 +1,56 @@ +package org.whispersystems.textsecuregcm.grpc; + +import java.io.IOException; +import org.signal.chat.challenge.AnswerChallengeRequest; +import org.signal.chat.challenge.AnswerChallengeResponse; +import org.signal.chat.challenge.SimpleChallengeGrpc; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice; +import org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; +import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; + +public class ChallengeGrpcService extends SimpleChallengeGrpc.ChallengeImplBase { + + private final AccountsManager accountsManager; + private final RateLimitChallengeManager rateLimitChallengeManager; + private final ChallengeConstraintChecker challengeConstraintChecker; + + public ChallengeGrpcService(final AccountsManager accountsManager, + final RateLimitChallengeManager rateLimitChallengeManager, + final ChallengeConstraintChecker challengeConstraintChecker) { + this.accountsManager = accountsManager; + this.rateLimitChallengeManager = rateLimitChallengeManager; + this.challengeConstraintChecker = challengeConstraintChecker; + } + + @Override + public AnswerChallengeResponse handleChallengeResponse(final AnswerChallengeRequest request) + throws RateLimitExceededException, IOException { + + final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice(); + final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier()) + .orElseThrow(() -> GrpcExceptions.invalidCredentials("invalid credentials")); + + final ChallengeConstraintChecker.ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraintsGrpc( + account); + + final boolean success = switch (request.getRequestCase()) { + case PUSH -> constraints.pushPermitted() && rateLimitChallengeManager.answerPushChallenge(account, + request.getPush().getChallenge()); + case CAPTCHA -> rateLimitChallengeManager.answerCaptchaChallenge( + account, + request.getCaptcha().getCaptcha(), + RequestAttributesUtil.getRemoteAddress().getHostAddress(), + RequestAttributesUtil.getUserAgent().orElse(null), + constraints.captchaScoreThreshold()); + case REQUEST_NOT_SET -> throw GrpcExceptions.fieldViolation("request", "Must set request type"); + }; + + return AnswerChallengeResponse.newBuilder() + .setSuccess(success) + .build(); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java index e66917e5f..0e573d66c 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java @@ -24,6 +24,7 @@ import org.whispersystems.textsecuregcm.spam.ChallengeType; import org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.util.Util; +import javax.annotation.Nullable; public class RateLimitChallengeManager { @@ -52,7 +53,7 @@ public class RateLimitChallengeManager { this.rateLimitChallengeListeners = rateLimitChallengeListeners; } - public void answerPushChallenge(final Account account, final String challenge) throws RateLimitExceededException { + public boolean answerPushChallenge(final Account account, final String challenge) throws RateLimitExceededException { rateLimiters.getPushChallengeAttemptLimiter().validate(account.getUuid()); final boolean challengeSuccess = pushChallengeManager.answerChallenge(account, challenge); @@ -61,12 +62,13 @@ public class RateLimitChallengeManager { rateLimiters.getPushChallengeSuccessLimiter().validate(account.getUuid()); resetRateLimits(account, ChallengeType.PUSH); } + return challengeSuccess; } public boolean answerCaptchaChallenge(final Account account, final String captcha, final String mostRecentProxyIp, - final String userAgent, + @Nullable final String userAgent, final Optional scoreThreshold) throws RateLimitExceededException, IOException { rateLimiters.getCaptchaChallengeAttemptLimiter().validate(account.getUuid()); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/spam/ChallengeConstraintChecker.java b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ChallengeConstraintChecker.java index 30ae50438..9d52727be 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/spam/ChallengeConstraintChecker.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/spam/ChallengeConstraintChecker.java @@ -10,7 +10,9 @@ import org.whispersystems.textsecuregcm.storage.Account; public interface ChallengeConstraintChecker { - record ChallengeConstraints(boolean pushPermitted, Optional captchaScoreThreshold) {} + record ChallengeConstraints(boolean pushPermitted, Optional captchaScoreThreshold) { + static final ChallengeConstraints ALL_PERMITTED = new ChallengeConstraints(true, Optional.empty()); + } /** * Retrieve constraints for captcha and push challenges @@ -18,9 +20,29 @@ public interface ChallengeConstraintChecker { * @param authenticatedAccount The authenticated account attempting to request or solve a challenge * @return ChallengeConstraints indicating what constraints should be applied to challenges */ - ChallengeConstraints challengeConstraints(ContainerRequestContext requestContext, Account authenticatedAccount); + ChallengeConstraints challengeConstraintsHttp(ContainerRequestContext requestContext, Account authenticatedAccount); + + /** + * Retrieve constraints for captcha and push challenges + * + * @param authenticatedAccount The authenticated account attempting to request or solve a challenge + * @return ChallengeConstraints indicating what constraints should be applied to challenges + */ + ChallengeConstraints challengeConstraintsGrpc(Account authenticatedAccount); static ChallengeConstraintChecker noop() { - return (account, ctx) -> new ChallengeConstraints(true, Optional.empty()); + return new ChallengeConstraintChecker() { + + @Override + public ChallengeConstraints challengeConstraintsHttp(final ContainerRequestContext requestContext, + final Account authenticatedAccount) { + return ChallengeConstraints.ALL_PERMITTED; + } + + @Override + public ChallengeConstraints challengeConstraintsGrpc(final Account authenticatedAccount) { + return ChallengeConstraints.ALL_PERMITTED; + } + }; } } diff --git a/service/src/main/proto/org/signal/chat/challenge.proto b/service/src/main/proto/org/signal/chat/challenge.proto new file mode 100644 index 000000000..a3eef30c8 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/challenge.proto @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.challenge; + +import "org/signal/chat/require.proto"; + +service Challenge { + option (require.auth) = AUTH_ONLY_AUTHENTICATED; + // Submit proof of a challenge completion + + // Some server endpoints (the "send message" endpoint, for example) may return + // a response indicating the client must complete a challenge before continuing. + // Clients may use this endpoint to provide proof of a completed challenge. + // If successful, the client may then continue their original operation. + rpc HandleChallengeResponse(AnswerChallengeRequest) returns (AnswerChallengeResponse) {} +} + +message AnswerChallengeRequest { + + message AnswerPushChallengeRequest { + // The challenge string provided to the client via a push payload + string challenge = 1 [(require.nonEmpty) = true]; + } + + message AnswerCaptchaChallengeRequest { + // A string representing a solved captcha + // Example: signal-hcaptcha.30b01b46-d8c9-4c30-bbd7-9719acfe0c10.challenge.abcdefg1345 + string captcha = 1 [(require.nonEmpty) = true]; + } + + // The opaque token id from the ChallengeRequired response returned by the + // server endpoint that requested the challenge + string token = 1 [(require.nonEmpty) = true]; + + oneof request { + AnswerPushChallengeRequest push = 2; + AnswerCaptchaChallengeRequest captcha = 3; + } + +} + +message AnswerChallengeResponse { + // Whether the challenge proof was accepted + bool success = 1; +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java index 448745394..c72fa4bf9 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java @@ -68,7 +68,7 @@ class ChallengeControllerTest { when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT)); when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_TWO)); - when(challengeConstraintChecker.challengeConstraints(any(), any())) + when(challengeConstraintChecker.challengeConstraintsHttp(any(), any())) .thenReturn(new ChallengeConstraints(true, Optional.empty())); } @@ -133,7 +133,7 @@ class ChallengeControllerTest { if (hasThreshold) { - when(challengeConstraintChecker.challengeConstraints(any(), any())) + when(challengeConstraintChecker.challengeConstraintsHttp(any(), any())) .thenReturn(new ChallengeConstraints(true, Optional.of(0.5F))); } final Response response = EXTENSION.target("/v1/challenge") @@ -236,7 +236,7 @@ class ChallengeControllerTest { @Test void testRequestPushChallengeNotPermitted() { - when(challengeConstraintChecker.challengeConstraints(any(), any())) + when(challengeConstraintChecker.challengeConstraintsHttp(any(), any())) .thenReturn(new ChallengeConstraints(false, Optional.empty())); final Response response = EXTENSION.target("/v1/challenge/push") @@ -249,7 +249,7 @@ class ChallengeControllerTest { @Test void testAnswerPushChallengeNotPermitted() { - when(challengeConstraintChecker.challengeConstraints(any(), any())) + when(challengeConstraintChecker.challengeConstraintsHttp(any(), any())) .thenReturn(new ChallengeConstraints(false, Optional.empty())); final String pushChallengeJson = """ diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcServiceTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcServiceTest.java new file mode 100644 index 000000000..3d1f81528 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ChallengeGrpcServiceTest.java @@ -0,0 +1,160 @@ +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junitpioneer.jupiter.cartesian.CartesianTest; +import org.junitpioneer.jupiter.cartesian.CartesianTest.Values; +import org.mockito.Mock; +import org.signal.chat.challenge.AnswerChallengeRequest; +import org.signal.chat.challenge.AnswerChallengeResponse; +import org.signal.chat.challenge.ChallengeGrpc; +import org.whispersystems.textsecuregcm.controllers.RateLimitExceededException; +import org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager; +import org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; + +public class ChallengeGrpcServiceTest extends + SimpleBaseGrpcTest { + + @Mock + private AccountsManager accountsManager; + + @Mock + private Account account; + + @Mock + private RateLimitChallengeManager rateLimitChallengeManager; + + @Mock + private ChallengeConstraintChecker challengeConstraintChecker; + + @Override + protected ChallengeGrpcService createServiceBeforeEachTest() { + when(account.getUuid()).thenReturn(AUTHENTICATED_ACI); + when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(account)); + return new ChallengeGrpcService( + accountsManager, + rateLimitChallengeManager, + challengeConstraintChecker + ); + } + + @CartesianTest + void handlePushChallengeResponse( + @Values(booleans = {true, false}) final boolean pushPermitted, + @Values(booleans = {true, false}) final boolean success) throws RateLimitExceededException { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(pushPermitted, Optional.empty())); + when(rateLimitChallengeManager.answerPushChallenge(account, "foo")).thenReturn(success); + final AnswerChallengeResponse response = authenticatedServiceStub().handleChallengeResponse( + AnswerChallengeRequest.newBuilder() + .setToken("token") + .setPush(AnswerChallengeRequest.AnswerPushChallengeRequest.newBuilder() + .setChallenge("foo") + .build()) + .build()); + assertEquals(pushPermitted && success, response.getSuccess()); + } + + @Test + void handlePushChallengeResponseRateLimitExceeded() throws RateLimitExceededException { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + final Duration retryAfter = Duration.ofMinutes(1); + doThrow(new RateLimitExceededException(retryAfter)).when(rateLimitChallengeManager) + .answerPushChallenge(account, "foo"); + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> authenticatedServiceStub().handleChallengeResponse(AnswerChallengeRequest.newBuilder() + .setToken("token") + .setPush(AnswerChallengeRequest.AnswerPushChallengeRequest.newBuilder() + .setChallenge("foo") + .build()) + .build())); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void handleCaptchaChallengeResponse(final boolean captchaSuccess) throws RateLimitExceededException, IOException { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + when(rateLimitChallengeManager.answerCaptchaChallenge(any(), any(), any(), any(), any())).thenReturn( + captchaSuccess); + final AnswerChallengeResponse response = authenticatedServiceStub().handleChallengeResponse( + AnswerChallengeRequest.newBuilder() + .setToken("token") + .setCaptcha(AnswerChallengeRequest.AnswerCaptchaChallengeRequest.newBuilder() + .setCaptcha("captcha") + .build()) + .build()); + assertEquals(captchaSuccess, response.getSuccess()); + } + + @Test + void handleCaptchaChallengeResponseRateLimitExceeded() throws RateLimitExceededException, IOException { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + final Duration retryAfter = Duration.ofMinutes(1); + doThrow(new RateLimitExceededException(retryAfter)).when(rateLimitChallengeManager) + .answerCaptchaChallenge(any(), any(), any(), any(), any()); + GrpcTestUtils.assertRateLimitExceeded(retryAfter, + () -> authenticatedServiceStub().handleChallengeResponse(AnswerChallengeRequest.newBuilder() + .setToken("token") + .setCaptcha(AnswerChallengeRequest.AnswerCaptchaChallengeRequest.newBuilder() + .setCaptcha("captcha") + .build()) + .build())); + } + + @Test + void emptyRequestType() { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + GrpcTestUtils.assertStatusInvalidArgument( + () -> authenticatedServiceStub().handleChallengeResponse(AnswerChallengeRequest.newBuilder().build())); + } + + @Test + void emptyToken() { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + GrpcTestUtils.assertStatusInvalidArgument( + () -> authenticatedServiceStub().handleChallengeResponse(AnswerChallengeRequest.newBuilder() + .setPush(AnswerChallengeRequest.AnswerPushChallengeRequest.newBuilder() + .build()) + .build())); + } + + @Test + void emptyPushChallenge() { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + GrpcTestUtils.assertStatusInvalidArgument( + () -> authenticatedServiceStub().handleChallengeResponse(AnswerChallengeRequest.newBuilder() + .setToken("token") + .setPush(AnswerChallengeRequest.AnswerPushChallengeRequest.newBuilder() + .build()) + .build())); + } + + @Test + void emptyCaptcha() { + when(challengeConstraintChecker.challengeConstraintsGrpc(account)).thenReturn( + new ChallengeConstraintChecker.ChallengeConstraints(true, Optional.empty())); + GrpcTestUtils.assertStatusInvalidArgument( + () -> authenticatedServiceStub().handleChallengeResponse(AnswerChallengeRequest.newBuilder() + .setToken("token") + .setCaptcha(AnswerChallengeRequest.AnswerCaptchaChallengeRequest.newBuilder() + .build()) + .build())); + } +}