From d1e38737ce2175d49ca71d69e64512d3f89c931e Mon Sep 17 00:00:00 2001 From: Chris Eager Date: Tue, 16 May 2023 10:28:41 -0500 Subject: [PATCH] Support ID token at `PUT /v1/config` and `DELETE /v1/config` --- event-logger/src/main/kotlin/events.kt | 4 +- service/config/sample.yml | 11 + .../textsecuregcm/WhisperServerService.java | 7 + .../RemoteConfigConfiguration.java | 7 + .../controllers/RemoteConfigController.java | 68 +++++- .../RemoteConfigControllerTest.java | 224 ++++++++++++------ 6 files changed, 229 insertions(+), 92 deletions(-) diff --git a/event-logger/src/main/kotlin/events.kt b/event-logger/src/main/kotlin/events.kt index 8a68eef17..b1bbbc5a4 100644 --- a/event-logger/src/main/kotlin/events.kt +++ b/event-logger/src/main/kotlin/events.kt @@ -24,7 +24,7 @@ sealed interface Event @Serializable data class RemoteConfigSetEvent( - val token: String, + val identity: String, val name: String, val percentage: Int, val defaultValue: String? = null, @@ -35,6 +35,6 @@ data class RemoteConfigSetEvent( @Serializable data class RemoteConfigDeleteEvent( - val token: String, + val identity: String, val name: String, ) : Event diff --git a/service/config/sample.yml b/service/config/sample.yml index b5593f379..0f4605980 100644 --- a/service/config/sample.yml +++ b/service/config/sample.yml @@ -302,6 +302,17 @@ appConfig: remoteConfig: authorizedTokens: secret://remoteConfig.authorizedTokens + authorizedUsers: + - # 1st authorized user + - # 2nd authorized user + - # ... + - # Nth authorized user + requiredHostedDomain: example.com + audiences: + - # 1st audience + - # 2nd audience + - # ... + - # Nth audience globalConfig: # keys and values that are given to clients on GET /v1/config EXAMPLE_KEY: VALUE diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 084497d40..5d54ffa80 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -11,6 +11,9 @@ import com.amazonaws.ClientConfiguration; import com.amazonaws.auth.AWSCredentialsProviderChain; import com.amazonaws.services.dynamodbv2.AmazonDynamoDB; import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.apache.v2.ApacheHttpTransport; +import com.google.api.client.json.gson.GsonFactory; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.logging.LoggingOptions; import com.google.common.collect.ImmutableMap; @@ -753,6 +756,10 @@ public class WhisperServerService extends Application authorizedUsers, + @NotNull String requiredHostedDomain, + @NotNull @NotEmpty List audiences, @NotNull Map globalConfig) { + } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java index a6f0e5bdd..70fd25863 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java @@ -6,6 +6,8 @@ package org.whispersystems.textsecuregcm.controllers; import com.codahale.metrics.annotation.Timed; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; import com.google.common.annotations.VisibleForTesting; import io.dropwizard.auth.Auth; import io.swagger.v3.oas.annotations.tags.Tag; @@ -16,10 +18,12 @@ import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.annotation.Nullable; import javax.validation.Valid; import javax.validation.constraints.NotNull; import javax.ws.rs.Consumes; @@ -51,15 +55,26 @@ public class RemoteConfigController { private final RemoteConfigsManager remoteConfigsManager; private final AdminEventLogger adminEventLogger; private final List configAuthTokens; + private final Set configAuthUsers; private final Map globalConfig; + private final String requiredHostedDomain; + + private final GoogleIdTokenVerifier googleIdTokenVerifier; + private static final String GLOBAL_CONFIG_PREFIX = "global."; - public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger, List configAuthTokens, Map globalConfig) { + public RemoteConfigController(RemoteConfigsManager remoteConfigsManager, AdminEventLogger adminEventLogger, + List configAuthTokens, Set configAuthUsers, String requiredHostedDomain, List audience, + final GoogleIdTokenVerifier.Builder googleIdTokenVerifierBuilder, Map globalConfig) { this.remoteConfigsManager = remoteConfigsManager; this.adminEventLogger = Objects.requireNonNull(adminEventLogger); this.configAuthTokens = configAuthTokens; + this.configAuthUsers = configAuthUsers; this.globalConfig = globalConfig; + + this.requiredHostedDomain = requiredHostedDomain; + this.googleIdTokenVerifier = googleIdTokenVerifierBuilder.setAudience(audience).build(); } @Timed @@ -89,9 +104,9 @@ public class RemoteConfigController { @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public void set(@HeaderParam("Config-Token") String configToken, @NotNull @Valid RemoteConfig config) { - if (!isAuthorized(configToken)) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } + + final String authIdentity = getAuthIdentity(configToken) + .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)); if (config.getName().startsWith(GLOBAL_CONFIG_PREFIX)) { throw new WebApplicationException(Response.Status.FORBIDDEN); @@ -99,7 +114,7 @@ public class RemoteConfigController { adminEventLogger.logEvent( new RemoteConfigSetEvent( - configToken, + authIdentity, config.getName(), config.getPercentage(), config.getDefaultValue(), @@ -113,21 +128,48 @@ public class RemoteConfigController { @DELETE @Path("/{name}") public void delete(@HeaderParam("Config-Token") String configToken, @PathParam("name") String name) { - if (!isAuthorized(configToken)) { - throw new WebApplicationException(Response.Status.UNAUTHORIZED); - } + final String authIdentity = getAuthIdentity(configToken) + .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)); if (name.startsWith(GLOBAL_CONFIG_PREFIX)) { throw new WebApplicationException(Response.Status.FORBIDDEN); } - adminEventLogger.logEvent(new RemoteConfigDeleteEvent(configToken, name)); + adminEventLogger.logEvent(new RemoteConfigDeleteEvent(authIdentity, name)); remoteConfigsManager.delete(name); } + private Optional getAuthIdentity(String token) { + return getAuthorizedGoogleIdentity(token) + .map(googleIdToken -> googleIdToken.getPayload().getEmail()) + .or(() -> Optional.ofNullable(isAuthorized(token) ? token : null)); + } + + private Optional getAuthorizedGoogleIdentity(String token) { + try { + final @Nullable GoogleIdToken googleIdToken = googleIdTokenVerifier.verify(token); + + if (googleIdToken != null + && googleIdToken.getPayload().getHostedDomain().equals(requiredHostedDomain) + && googleIdToken.getPayload().getEmailVerified() + && configAuthUsers.contains(googleIdToken.getPayload().getEmail())) { + + return Optional.of(googleIdToken); + } + + return Optional.empty(); + + } catch (final Exception ignored) { + return Optional.empty(); + } + } + @VisibleForTesting - public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage, Set uuidsInBucket) { - if (uuidsInBucket.contains(uid)) return true; + public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage, + Set uuidsInBucket) { + if (uuidsInBucket.contains(uid)) { + return true; + } ByteBuffer bb = ByteBuffer.wrap(new byte[16]); bb.putLong(uid.getMostSignificantBits()); @@ -135,8 +177,8 @@ public class RemoteConfigController { digest.update(bb.array()); - byte[] hash = digest.digest(hashKey); - int bucket = (int)(Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100); + byte[] hash = digest.digest(hashKey); + int bucket = (int) (Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100); return bucket < configPercentage; } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java index 2956b5300..d70fb4083 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/tests/controllers/RemoteConfigControllerTest.java @@ -6,6 +6,8 @@ package org.whispersystems.textsecuregcm.tests.controllers; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; @@ -13,12 +15,15 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; import com.google.common.collect.ImmutableSet; import io.dropwizard.auth.PolymorphicAuthValueFactoryProvider; import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; import io.dropwizard.testing.junit5.ResourceExtension; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; @@ -26,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; +import java.util.stream.Stream; import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -34,6 +40,9 @@ import org.junit.jupiter.api.AfterEach; 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.mockito.ArgumentCaptor; import org.signal.event.NoOpAdminEventLogger; import org.whispersystems.textsecuregcm.auth.AuthenticatedAccount; @@ -50,7 +59,19 @@ import org.whispersystems.textsecuregcm.tests.util.AuthHelper; class RemoteConfigControllerTest { private static final RemoteConfigsManager remoteConfigsManager = mock(RemoteConfigsManager.class); - private static final List remoteConfigsAuth = List.of("foo", "bar"); + private static final List remoteConfigsAuth = List.of("foo", "bar"); + + private static final Set remoteConfigsUsers = Set.of("user1@example.com", "user2@example.com"); + + private static final String requiredHostedDomain = "example.com"; + private static final GoogleIdTokenVerifier.Builder googleIdVerificationTokenBuilder = mock( + GoogleIdTokenVerifier.Builder.class); + private static final GoogleIdTokenVerifier googleIdTokenVerifier = mock(GoogleIdTokenVerifier.class); + + static { + when(googleIdVerificationTokenBuilder.setAudience(any())).thenReturn(googleIdVerificationTokenBuilder); + when(googleIdVerificationTokenBuilder.build()).thenReturn(googleIdTokenVerifier); + } private static final ResourceExtension resources = ResourceExtension.builder() .addProvider(AuthHelper.getAuthFilter()) @@ -58,14 +79,17 @@ class RemoteConfigControllerTest { ImmutableSet.of(AuthenticatedAccount.class, DisabledPermittedAuthenticatedAccount.class))) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) .addProvider(new DeviceLimitExceededExceptionMapper()) - .addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth, Map.of("maxGroupSize", "42"))) + .addResource(new RemoteConfigController(remoteConfigsManager, new NoOpAdminEventLogger(), remoteConfigsAuth, + remoteConfigsUsers, requiredHostedDomain, Collections.singletonList("aud.example.com"), + googleIdVerificationTokenBuilder, Map.of("maxGroupSize", "42"))) .build(); @BeforeEach - void setup() { + void setup() throws Exception { when(remoteConfigsManager.getAll()).thenReturn(new LinkedList<>() {{ - add(new RemoteConfig("android.stickers", 25, Set.of(AuthHelper.DISABLED_UUID, AuthHelper.INVALID_UUID), null, null, null)); + add(new RemoteConfig("android.stickers", 25, Set.of(AuthHelper.DISABLED_UUID, AuthHelper.INVALID_UUID), null, + null, null)); add(new RemoteConfig("ios.stickers", 50, Set.of(), null, null, null)); add(new RemoteConfig("always.true", 100, Set.of(), null, null, null)); add(new RemoteConfig("only.special", 0, Set.of(AuthHelper.VALID_UUID), null, null, null)); @@ -76,6 +100,24 @@ class RemoteConfigControllerTest { add(new RemoteConfig("linked.config.1", 50, Set.of(), null, null, "linked.config.0")); add(new RemoteConfig("unlinked.config", 50, Set.of(), null, null, null)); }}); + + final Map googleIdTokens = new HashMap<>(); + + for (int i = 1; i <= 3; i++) { + final String user = "user" + i; + final GoogleIdToken googleIdToken = mock(GoogleIdToken.class); + final GoogleIdToken.Payload payload = mock(GoogleIdToken.Payload.class); + when(googleIdToken.getPayload()).thenReturn(payload); + + when(payload.getEmail()).thenReturn(user + "@" + requiredHostedDomain); + when(payload.getEmailVerified()).thenReturn(true); + when(payload.getHostedDomain()).thenReturn(requiredHostedDomain); + + googleIdTokens.put(user + ".valid", googleIdToken); + } + + when(googleIdTokenVerifier.verify(anyString())) + .thenAnswer(answer -> googleIdTokens.get(answer.getArgument(0, String.class))); } @AfterEach @@ -173,28 +215,28 @@ class RemoteConfigControllerTest { assertThat(allUnlinkedConfigsMatched).isFalse(); } - @Test void testRetrieveConfigUnauthorized() { Response response = resources.getJerseyTest() - .target("/v1/config/") - .request() - .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) - .get(); + .target("/v1/config/") + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD)) + .get(); assertThat(response.getStatus()).isEqualTo(401); verifyNoMoreInteractions(remoteConfigsManager); } - - @Test - void testSetConfig() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testSetConfig(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(204); @@ -207,13 +249,15 @@ class RemoteConfigControllerTest { assertThat(captor.getValue().getUuids()).isEmpty(); } - @Test - void testSetConfigValued() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testSetConfigValued(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("value.sometimes", 50, Set.of(), "a", "b", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("value.sometimes", 50, Set.of(), "a", "b", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(204); @@ -226,19 +270,21 @@ class RemoteConfigControllerTest { assertThat(captor.getValue().getUuids()).isEmpty(); } - @Test - void testSetConfigWithHashKey() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testSetConfigWithHashKey(final String configToken) { Response response1 = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("linked.config.0", 50, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("linked.config.0", 50, Set.of(), "FALSE", "TRUE", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response1.getStatus()).isEqualTo(204); Response response2 = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") + .target("/v1/config") + .request() + .header("Config-Token", configToken) .put(Entity.entity(new RemoteConfig("linked.config.1", 50, Set.of(), "FALSE", "TRUE", "linked.config.0"), MediaType.APPLICATION_JSON_TYPE)); assertThat(response2.getStatus()).isEqualTo(204); @@ -262,13 +308,15 @@ class RemoteConfigControllerTest { assertThat(capture2.getHashKey()).isEqualTo("linked.config.0"); } - @Test - void testSetConfigUnauthorized() { + @ParameterizedTest + @MethodSource("unauthorizedTokens") + void testSetConfigUnauthorized(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "baz") - .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(401); @@ -278,59 +326,66 @@ class RemoteConfigControllerTest { @Test void testSetConfigMissingUnauthorized() { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .put(Entity.entity(new RemoteConfig("android.stickers", 88, Set.of(), "FALSE", "TRUE", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(401); verifyNoMoreInteractions(remoteConfigsManager); } - @Test - void testSetConfigBadName() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testSetConfigBadName(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("android-stickers", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("android-stickers", 88, Set.of(), "FALSE", "TRUE", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(422); verifyNoMoreInteractions(remoteConfigsManager); } - @Test - void testSetConfigEmptyName() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testSetConfigEmptyName(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(422); verifyNoMoreInteractions(remoteConfigsManager); } - @Test - void testSetGlobalConfig() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testSetGlobalConfig(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config") - .request() - .header("Config-Token", "foo") - .put(Entity.entity(new RemoteConfig("global.maxGroupSize", 88, Set.of(), "FALSE", "TRUE", null), MediaType.APPLICATION_JSON_TYPE)); + .target("/v1/config") + .request() + .header("Config-Token", configToken) + .put(Entity.entity(new RemoteConfig("global.maxGroupSize", 88, Set.of(), "FALSE", "TRUE", null), + MediaType.APPLICATION_JSON_TYPE)); assertThat(response.getStatus()).isEqualTo(403); verifyNoMoreInteractions(remoteConfigsManager); } - @Test - void testDelete() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testDelete(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config/android.stickers") - .request() - .header("Config-Token", "foo") - .delete(); + .target("/v1/config/android.stickers") + .request() + .header("Config-Token", configToken) + .delete(); assertThat(response.getStatus()).isEqualTo(204); @@ -341,23 +396,24 @@ class RemoteConfigControllerTest { @Test void testDeleteUnauthorized() { Response response = resources.getJerseyTest() - .target("/v1/config/android.stickers") - .request() - .header("Config-Token", "baz") - .delete(); + .target("/v1/config/android.stickers") + .request() + .header("Config-Token", "baz") + .delete(); assertThat(response.getStatus()).isEqualTo(401); verifyNoMoreInteractions(remoteConfigsManager); } - @Test - void testDeleteGlobalConfig() { + @ParameterizedTest + @MethodSource("authorizedTokens") + void testDeleteGlobalConfig(final String configToken) { Response response = resources.getJerseyTest() - .target("/v1/config/global.maxGroupSize") - .request() - .header("Config-Token", "foo") - .delete(); + .target("/v1/config/global.maxGroupSize") + .request() + .header("Config-Token", configToken) + .delete(); assertThat(response.getStatus()).isEqualTo(403); verifyNoMoreInteractions(remoteConfigsManager); } @@ -383,11 +439,25 @@ class RemoteConfigControllerTest { } for (RemoteConfig config : remoteConfigList) { - double targetNumber = iterations * (config.getPercentage() / 100.0); - double variance = targetNumber * 0.01; + double targetNumber = iterations * (config.getPercentage() / 100.0); + double variance = targetNumber * 0.01; - assertThat(enabledMap.get(config.getName())).isBetween((int)(targetNumber - variance), (int)(targetNumber + variance)); + assertThat(enabledMap.get(config.getName())).isBetween((int) (targetNumber - variance), + (int) (targetNumber + variance)); } + } + static Stream authorizedTokens() { + return Stream.of( + Arguments.of("foo"), + Arguments.of("user1.valid") + ); + } + + static Stream unauthorizedTokens() { + return Stream.of( + Arguments.of("baz"), + Arguments.of("user3.valid") + ); } }