diff --git a/service/config/sample.yml b/service/config/sample.yml index 5b2dd8329..612b91f6e 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -433,3 +433,6 @@ registrationService: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz AAAAAAAAAAAAAAAAAAAA -----END CERTIFICATE----- + +callLink: + userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with calling frontend to generate auth tokens for Signal users diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java index d9dbaa60e..2cb12389a 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java @@ -20,6 +20,7 @@ import org.whispersystems.textsecuregcm.configuration.ArtServiceConfiguration; import org.whispersystems.textsecuregcm.configuration.AwsAttachmentsConfiguration; import org.whispersystems.textsecuregcm.configuration.BadgesConfiguration; import org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration; +import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration; import org.whispersystems.textsecuregcm.configuration.CdnConfiguration; import org.whispersystems.textsecuregcm.configuration.DatadogConfiguration; import org.whispersystems.textsecuregcm.configuration.DirectoryConfiguration; @@ -219,6 +220,11 @@ public class WhisperServerConfiguration extends Configuration { @JsonProperty private ArtServiceConfiguration artService; + @Valid + @NotNull + @JsonProperty + private CallLinkConfiguration callLink; + @Valid @NotNull @JsonProperty @@ -371,6 +377,10 @@ public class WhisperServerConfiguration extends Configuration { return datadog; } + public CallLinkConfiguration getCallLinkConfiguration() { + return callLink; + } + public UnidentifiedDeliveryConfiguration getDeliveryCertificate() { return unidentifiedDelivery; } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 82a65f713..ed5269b5b 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -89,6 +89,7 @@ import org.whispersystems.textsecuregcm.controllers.AccountControllerV2; import org.whispersystems.textsecuregcm.controllers.ArtController; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV2; import org.whispersystems.textsecuregcm.controllers.AttachmentControllerV3; +import org.whispersystems.textsecuregcm.controllers.CallLinkController; import org.whispersystems.textsecuregcm.controllers.CertificateController; import org.whispersystems.textsecuregcm.controllers.ChallengeController; import org.whispersystems.textsecuregcm.controllers.DeviceController; @@ -490,6 +491,9 @@ public class WhisperServerService extends Application usernameTimestampTruncator; + private final Clock clock; private final int truncateLength; @@ -41,14 +47,22 @@ public class ExternalServiceCredentialsGenerator { final byte[] userDerivationKey, final boolean prependUsername, final boolean truncateSignature, - final Clock clock, - final int truncateLength) { + final int truncateLength, + final String usernameTimestampPrefix, + final Function usernameTimestampTruncator, + final Clock clock) { this.key = requireNonNull(key); this.userDerivationKey = requireNonNull(userDerivationKey); this.prependUsername = prependUsername; this.truncateSignature = truncateSignature; + this.usernameTimestampPrefix = usernameTimestampPrefix; + this.usernameTimestampTruncator = usernameTimestampTruncator; this.clock = requireNonNull(clock); this.truncateLength = truncateLength; + + if (hasUsernameTimestampPrefix() ^ hasUsernameTimestampTruncator()) { + throw new RuntimeException("Configured to have only one of (usernameTimestampPrefix, usernameTimestampTruncator)"); + } } /** @@ -66,13 +80,34 @@ public class ExternalServiceCredentialsGenerator { * @return an instance of {@link ExternalServiceCredentials} */ public ExternalServiceCredentials generateFor(final String identity) { + if (usernameIsTimestamp()) { + throw new RuntimeException("Configured to use timestamp as username"); + } + + return generate(identity); + } + + /** + * Generates `ExternalServiceCredentials` using a prefix concatenated with a truncated timestamp as the username, following this generator's configuration. + * @return an instance of {@link ExternalServiceCredentials} + */ + public ExternalServiceCredentials generateWithTimestampAsUsername() { + if (!usernameIsTimestamp()) { + throw new RuntimeException("Not configured to use timestamp as username"); + } + + final String truncatedTimestampSeconds = String.valueOf(usernameTimestampTruncator.apply(clock.instant()).getEpochSecond()); + return generate(usernameTimestampPrefix + DELIMITER + truncatedTimestampSeconds); + } + + private ExternalServiceCredentials generate(final String identity) { final String username = shouldDeriveUsername() ? hmac256TruncatedToHexString(userDerivationKey, identity, truncateLength) : identity; final long currentTimeSeconds = currentTimeSeconds(); - final String dataToSign = username + DELIMITER + currentTimeSeconds; + final String dataToSign = usernameIsTimestamp() ? username : username + DELIMITER + currentTimeSeconds; final String signature = truncateSignature ? hmac256TruncatedToHexString(key, dataToSign, truncateLength) @@ -84,7 +119,7 @@ public class ExternalServiceCredentialsGenerator { } /** - * In certain cases, identity (as it was passed to `generateFor` method) + * In certain cases, identity (as it was passed to `generate` method) * is a part of the signature (`password`, in terms of `ExternalServiceCredentials`) string itself. * For such cases, this method returns the value of the identity string. * @param password `password` part of `ExternalServiceCredentials` @@ -96,9 +131,15 @@ public class ExternalServiceCredentialsGenerator { return Optional.empty(); } // checking for the case of unexpected format - return StringUtils.countMatches(password, DELIMITER) == 2 - ? Optional.of(password.substring(0, password.indexOf(DELIMITER))) - : Optional.empty(); + if (StringUtils.countMatches(password, DELIMITER) == 2) { + if (usernameIsTimestamp()) { + final int indexOfSecondDelimiter = password.indexOf(DELIMITER, password.indexOf(DELIMITER) + 1); + return Optional.of(password.substring(0, indexOfSecondDelimiter)); + } else { + return Optional.of(password.substring(0, password.indexOf(DELIMITER))); + } + } + return Optional.empty(); } /** @@ -115,7 +156,7 @@ public class ExternalServiceCredentialsGenerator { // making sure password format matches our expectations based on the generator configuration if (parts.length == 3 && prependUsername) { - final String username = parts[0]; + final String username = usernameIsTimestamp() ? parts[0] + DELIMITER + parts[1] : parts[0]; // username has to match the one from `credentials` if (!credentials.username().equals(username)) { return Optional.empty(); @@ -130,7 +171,7 @@ public class ExternalServiceCredentialsGenerator { return Optional.empty(); } - final String signedData = credentials.username() + DELIMITER + timestampSeconds; + final String signedData = usernameIsTimestamp() ? credentials.username() : credentials.username() + DELIMITER + timestampSeconds; final String expectedSignature = truncateSignature ? hmac256TruncatedToHexString(key, signedData, truncateLength) : hmac256ToHexString(key, signedData); @@ -158,6 +199,18 @@ public class ExternalServiceCredentialsGenerator { return userDerivationKey.length > 0; } + private boolean hasUsernameTimestampPrefix() { + return usernameTimestampPrefix != null; + } + + private boolean hasUsernameTimestampTruncator() { + return usernameTimestampTruncator != null; + } + + private boolean usernameIsTimestamp() { + return hasUsernameTimestampPrefix() && hasUsernameTimestampTruncator(); + } + private long currentTimeSeconds() { return clock.instant().getEpochSecond(); } @@ -174,6 +227,10 @@ public class ExternalServiceCredentialsGenerator { private int truncateLength = 10; + private String usernameTimestampPrefix = null; + + private Function usernameTimestampTruncator = null; + private Clock clock = Clock.systemUTC(); @@ -208,9 +265,15 @@ public class ExternalServiceCredentialsGenerator { return this; } + public Builder withUsernameTimestampTruncatorAndPrefix(final Function truncator, final String prefix) { + this.usernameTimestampTruncator = truncator; + this.usernameTimestampPrefix = prefix; + return this; + } + public ExternalServiceCredentialsGenerator build() { return new ExternalServiceCredentialsGenerator( - key, userDerivationKey, prependUsername, truncateSignature, clock, truncateLength); + key, userDerivationKey, prependUsername, truncateSignature, truncateLength, usernameTimestampPrefix, usernameTimestampTruncator, clock); } } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CallLinkConfiguration.java b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CallLinkConfiguration.java new file mode 100644 index 000000000..031cd6731 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/configuration/CallLinkConfiguration.java @@ -0,0 +1,9 @@ +package org.whispersystems.textsecuregcm.configuration; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.whispersystems.textsecuregcm.util.ExactlySize; +import javax.validation.constraints.NotEmpty; +import java.util.HexFormat; + +public record CallLinkConfiguration (@ExactlySize({32}) byte[] userAuthenticationTokenSharedSecret) { +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallLinkController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallLinkController.java new file mode 100644 index 000000000..0bf0edf02 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallLinkController.java @@ -0,0 +1,56 @@ +package org.whispersystems.textsecuregcm.controllers; + +import com.codahale.metrics.annotation.Timed; +import com.google.common.annotations.VisibleForTesting; +import io.dropwizard.auth.Auth; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import java.time.Clock; +import java.time.temporal.ChronoUnit; +import java.util.Optional; + +@Path("/v1/call-link") +@io.swagger.v3.oas.annotations.tags.Tag(name = "CallLink") +public class CallLinkController { + @VisibleForTesting + public static final String ANONYMOUS_CREDENTIAL_PREFIX = "anon"; + + private final ExternalServiceCredentialsGenerator callingFrontendServiceCredentialGenerator; + + public CallLinkController( + ExternalServiceCredentialsGenerator callingFrontendServiceCredentialGenerator + ) { + this.callingFrontendServiceCredentialGenerator = callingFrontendServiceCredentialGenerator; + } + + public static ExternalServiceCredentialsGenerator credentialsGenerator(final CallLinkConfiguration cfg) { + return ExternalServiceCredentialsGenerator + .builder(cfg.userAuthenticationTokenSharedSecret()) + .withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), ANONYMOUS_CREDENTIAL_PREFIX) + .build(); + } + @Timed + @GET + @Path("/auth") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Generate credentials for calling frontend", + description = """ + These credentials enable clients to prove to calling frontend that they were a Signal user within the last day. + For client privacy, timestamps are truncated to 1 day granularity and the token does not include or derive from an ACI. + """ + ) + @ApiResponse(responseCode = "200", description = "`JSON` with generated credentials.", useReturnTypeSchema = true) + @ApiResponse(responseCode = "401", description = "Account authentication check failed.") + public ExternalServiceCredentials getAuth(@Auth AuthenticatedAccount auth) { + return callingFrontendServiceCredentialGenerator.generateWithTimestampAsUsername(); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java index 828f53a0a..a68e6a0da 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/auth/ExternalServiceCredentialsGeneratorTest.java @@ -8,9 +8,14 @@ package org.whispersystems.textsecuregcm.tests.auth; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256TruncatedToHexString; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; @@ -18,6 +23,7 @@ import org.whispersystems.textsecuregcm.util.MockUtils; import org.whispersystems.textsecuregcm.util.MutableClock; class ExternalServiceCredentialsGeneratorTest { + private static final String PREFIX = "prefix"; private static final String E164 = "+14152222222"; @@ -27,6 +33,42 @@ class ExternalServiceCredentialsGeneratorTest { private static final String TIME_SECONDS_STRING = Long.toString(TIME_SECONDS); + private static final String USERNAME_TIMESTAMP = PREFIX + ":" + Instant.ofEpochSecond(TIME_SECONDS).truncatedTo(ChronoUnit.DAYS).getEpochSecond(); + + private static final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); + + private static final ExternalServiceCredentialsGenerator standardGenerator = ExternalServiceCredentialsGenerator + .builder(new byte[32]) + .withClock(clock) + .build(); + + private static final ExternalServiceCredentials standardCredentials = standardGenerator.generateFor(E164); + + private static final ExternalServiceCredentialsGenerator usernameIsTimestampGenerator = ExternalServiceCredentialsGenerator + .builder(new byte[32]) + .withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), PREFIX) + .withClock(clock) + .build(); + + private static final ExternalServiceCredentials usernameIsTimestampCredentials = usernameIsTimestampGenerator.generateWithTimestampAsUsername(); + + @BeforeEach + public void before() throws Exception { + clock.setTimeMillis(TIME_MILLIS); + } + + @Test + void testInvalidConstructor() { + assertThrows(RuntimeException.class, () -> ExternalServiceCredentialsGenerator + .builder(new byte[32]) + .withUsernameTimestampTruncatorAndPrefix(null, PREFIX) + .build()); + + assertThrows(RuntimeException.class, () -> ExternalServiceCredentialsGenerator + .builder(new byte[32]) + .withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), null) + .build()); + } @Test void testGenerateDerivedUsername() { @@ -42,18 +84,13 @@ class ExternalServiceCredentialsGeneratorTest { @Test void testGenerateNoDerivedUsername() { - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - assertEquals(credentials.username(), E164); - assertTrue(credentials.password().startsWith(E164)); - assertEquals(credentials.password().split(":").length, 3); + assertEquals(standardCredentials.username(), E164); + assertTrue(standardCredentials.password().startsWith(E164)); + assertEquals(standardCredentials.password().split(":").length, 3); } @Test public void testNotPrependUsername() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator .builder(new byte[32]) .prependUsername(false) @@ -65,52 +102,68 @@ class ExternalServiceCredentialsGeneratorTest { assertEquals(credentials.password().split(":").length, 2); } + @Test + public void testWithUsernameIsTimestamp() { + assertEquals(USERNAME_TIMESTAMP, usernameIsTimestampCredentials.username()); + + final String[] passwordComponents = usernameIsTimestampCredentials.password().split(":"); + assertEquals(USERNAME_TIMESTAMP, passwordComponents[0] + ":" + passwordComponents[1]); + assertEquals(hmac256TruncatedToHexString(new byte[32], USERNAME_TIMESTAMP, 10), passwordComponents[2]); + } + @Test public void testValidateValid() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - assertEquals(generator.validateAndGetTimestamp(credentials).orElseThrow(), TIME_SECONDS); + assertEquals(standardGenerator.validateAndGetTimestamp(standardCredentials).orElseThrow(), TIME_SECONDS); + } + + @Test + public void testValidateValidWithUsernameIsTimestamp() { + final long expectedTimestamp = Instant.ofEpochSecond(TIME_SECONDS).truncatedTo(ChronoUnit.DAYS).getEpochSecond(); + assertEquals(expectedTimestamp, usernameIsTimestampGenerator.validateAndGetTimestamp(usernameIsTimestampCredentials).orElseThrow()); } @Test public void testValidateInvalid() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); + final ExternalServiceCredentials corruptedStandardUsername = new ExternalServiceCredentials( + standardCredentials.username(), standardCredentials.password().replace(E164, E164 + "0")); + final ExternalServiceCredentials corruptedStandardTimestamp = new ExternalServiceCredentials( + standardCredentials.username(), standardCredentials.password().replace(TIME_SECONDS_STRING, TIME_SECONDS_STRING + "0")); + final ExternalServiceCredentials corruptedStandardPassword = new ExternalServiceCredentials( + standardCredentials.username(), standardCredentials.password() + "0"); - final ExternalServiceCredentials corruptedUsername = new ExternalServiceCredentials( - credentials.username(), credentials.password().replace(E164, E164 + "0")); - final ExternalServiceCredentials corruptedTimestamp = new ExternalServiceCredentials( - credentials.username(), credentials.password().replace(TIME_SECONDS_STRING, TIME_SECONDS_STRING + "0")); - final ExternalServiceCredentials corruptedPassword = new ExternalServiceCredentials( - credentials.username(), credentials.password() + "0"); + final ExternalServiceCredentials corruptedUsernameTimestamp = new ExternalServiceCredentials( + usernameIsTimestampCredentials.username(), usernameIsTimestampCredentials.password().replace(USERNAME_TIMESTAMP, USERNAME_TIMESTAMP + + "0")); + final ExternalServiceCredentials corruptedUsernameTimestampPassword = new ExternalServiceCredentials( + usernameIsTimestampCredentials.username(), usernameIsTimestampCredentials.password() + "0"); - assertTrue(generator.validateAndGetTimestamp(corruptedUsername).isEmpty()); - assertTrue(generator.validateAndGetTimestamp(corruptedTimestamp).isEmpty()); - assertTrue(generator.validateAndGetTimestamp(corruptedPassword).isEmpty()); + assertTrue(standardGenerator.validateAndGetTimestamp(corruptedStandardUsername).isEmpty()); + assertTrue(standardGenerator.validateAndGetTimestamp(corruptedStandardTimestamp).isEmpty()); + assertTrue(standardGenerator.validateAndGetTimestamp(corruptedStandardPassword).isEmpty()); + + assertTrue(usernameIsTimestampGenerator.validateAndGetTimestamp(corruptedUsernameTimestamp).isEmpty()); + assertTrue(usernameIsTimestampGenerator.validateAndGetTimestamp(corruptedUsernameTimestampPassword).isEmpty()); } @Test public void testValidateWithExpiration() throws Exception { - final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS); - final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator - .builder(new byte[32]) - .withClock(clock) - .build(); - final ExternalServiceCredentials credentials = generator.generateFor(E164); - final long elapsedSeconds = 10000; clock.incrementSeconds(elapsedSeconds); - assertEquals(generator.validateAndGetTimestamp(credentials, elapsedSeconds + 1).orElseThrow(), TIME_SECONDS); - assertTrue(generator.validateAndGetTimestamp(credentials, elapsedSeconds - 1).isEmpty()); + assertEquals(standardGenerator.validateAndGetTimestamp(standardCredentials, elapsedSeconds + 1).orElseThrow(), TIME_SECONDS); + assertTrue(standardGenerator.validateAndGetTimestamp(standardCredentials, elapsedSeconds - 1).isEmpty()); + } + + @Test + public void testGetIdentityFromSignature() { + final String identity = standardGenerator.identityFromSignature(standardCredentials.password()).orElseThrow(); + assertEquals(E164, identity); + } + + @Test + public void testGetIdentityFromSignatureIsTimestamp() { + final String identity = usernameIsTimestampGenerator.identityFromSignature(usernameIsTimestampCredentials.password()).orElseThrow(); + assertEquals(USERNAME_TIMESTAMP, identity); } @Test diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java new file mode 100644 index 000000000..1e46d987a --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/CallLinkControllerTest.java @@ -0,0 +1,44 @@ +package org.whispersystems.textsecuregcm.tests.controllers; + +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials; +import org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator; +import org.whispersystems.textsecuregcm.configuration.CallLinkConfiguration; +import org.whispersystems.textsecuregcm.controllers.CallLinkController; +import org.whispersystems.textsecuregcm.tests.util.AuthHelper; +import org.whispersystems.textsecuregcm.util.MockUtils; +import org.whispersystems.textsecuregcm.util.SystemMapper; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@ExtendWith(DropwizardExtensionsSupport.class) +public class CallLinkControllerTest { + private static final CallLinkConfiguration callLinkConfiguration = MockUtils.buildMock( + CallLinkConfiguration.class, + cfg -> Mockito.when(cfg.userAuthenticationTokenSharedSecret()).thenReturn(new byte[32]) + ); + private static final ExternalServiceCredentialsGenerator callLinkCredentialsGenerator = CallLinkController.credentialsGenerator(callLinkConfiguration); + + private static final ResourceExtension resources = ResourceExtension.builder() + .setMapper(SystemMapper.jsonMapper()) + .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) + .addResource(new CallLinkController(callLinkCredentialsGenerator)) + .build(); + + @Test + void testGetAuth() { + ExternalServiceCredentials credentials = resources.getJerseyTest() + .target("/v1/call-link/auth") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .get(ExternalServiceCredentials.class); + + assertThat(credentials.username()).isNotEmpty(); + assertThat(credentials.password()).isNotEmpty(); + } +}