Skip to content

Commit

Permalink
Add EntraId integration tests
Browse files Browse the repository at this point in the history
   Verify authentication using Azure AD with service principals
  • Loading branch information
ggivo committed Dec 18, 2024
1 parent 3c6dbc9 commit 00de1ce
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 7 deletions.
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@
<version>0.1.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.cdimascio</groupId>
<artifactId>dotenv-java</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>

Expand All @@ -210,6 +216,11 @@
<artifactId>redis-authx-entraid</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.cdimascio</groupId>
<artifactId>dotenv-java</artifactId>
<scope>test</scope>
</dependency>
<!-- Start of core dependencies -->

<dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import redis.clients.authentication.core.TokenListener;
import redis.clients.authentication.core.TokenManager;

public class TokenBasedRedisCredentialsProvider implements StreamingCredentialsProvider {
public class TokenBasedRedisCredentialsProvider implements StreamingCredentialsProvider, AutoCloseable {

private final TokenManager tokenManager;

Expand Down Expand Up @@ -94,7 +94,8 @@ public Flux<RedisCredentials> credentials() {
* This method stops the TokenManager and completes the credentials sink, ensuring that all resources are properly released.
* It should be called when the credentials provider is no longer needed.
*/
public void shutdown() {
@Override
public void close() {
credentialsSink.tryEmitComplete();
tokenManager.stop();
}
Expand Down
177 changes: 177 additions & 0 deletions src/test/java/io/lettuce/authx/EntraIdIntegrationTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package io.lettuce.authx;

import io.lettuce.core.ClientOptions;
import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisFuture;
import io.lettuce.core.RedisURI;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.TimeoutOptions;
import io.lettuce.core.TransactionResult;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.async.RedisAsyncCommands;
import io.lettuce.core.cluster.ClusterClientOptions;
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import redis.clients.authentication.core.TokenAuthConfig;
import redis.clients.authentication.entraid.EntraIDTokenAuthConfigBuilder;

import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;

public class EntraIdIntegrationTests {

private static EntraIdTestContext testCtx = EntraIdTestContext.DEFAULT;;

@BeforeAll
public static void setup() {
Assumptions.assumeTrue(testCtx.host() != null && !testCtx.host().isEmpty(),
"Skipping EntraID tests. Redis host with enabled EntraId not provided!");
}

// T.1.1
// Verify authentication using Azure AD with service principals using Redis Standalone client
@Test
public void standaloneWithSecret_azureServicePrincipalIntegrationTest() throws ExecutionException, InterruptedException {
TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
.secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes()).build();

// Configure timeout options to assure fast test failover
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
.reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();

try (TokenBasedRedisCredentialsProvider credentialsProvider = new TokenBasedRedisCredentialsProvider(tokenAuthConfig)) {
RedisURI uri = RedisURI.builder().withHost(testCtx.host()).withPort(testCtx.port())
.withAuthentication(credentialsProvider).build();

try (RedisClient client = RedisClient.create(uri)) {
client.setOptions(clientOptions);

try (StatefulRedisConnection<String, String> connection = client.connect()) {
assertThat(connection.sync().aclWhoami()).isEqualTo(testCtx.getSpOID());
assertThat(connection.async().aclWhoami().get()).isEqualTo(testCtx.getSpOID());
assertThat(connection.reactive().aclWhoami().block()).isEqualTo(testCtx.getSpOID());
}
}
}
}

// T.1.1
// Verify authentication using Azure AD with service principals using Redis Cluster Client
@Test
public void clusterWithSecret_azureServicePrincipalIntegrationTest() throws ExecutionException, InterruptedException {
TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
.secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes()).build();

// Configure timeout options to assure fast test failover
ClusterClientOptions clientOptions = ClusterClientOptions.builder()
.socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
.reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();

try (TokenBasedRedisCredentialsProvider credentialsProvider = new TokenBasedRedisCredentialsProvider(tokenAuthConfig)) {
RedisURI uri = RedisURI.builder().withHost(testCtx.clusterHost().get(0)).withPort(testCtx.clusterPort())
.withAuthentication(credentialsProvider).build();

try (RedisClusterClient client = RedisClusterClient.create(uri)) {
client.setOptions(clientOptions);

try (StatefulRedisClusterConnection<String, String> connection = client.connect()) {
assertThat(connection.sync().aclWhoami()).isEqualTo(testCtx.getSpOID());
assertThat(connection.async().aclWhoami().get()).isEqualTo(testCtx.getSpOID());
assertThat(connection.reactive().aclWhoami().block()).isEqualTo(testCtx.getSpOID());

connection.getPartitions().forEach((partition) -> {
try (StatefulRedisConnection<?, ?> nodeConnection = connection.getConnection(partition.getNodeId())) {
assertThat(nodeConnection.sync().aclWhoami()).isEqualTo(testCtx.getSpOID());
}
});
}
}
}
}

// T.2.2
// Test that the Redis client is not blocked/interrupted during token renewal.
@Test
public void renewalDuringOperationsTest() throws InterruptedException, ExecutionException {
TokenAuthConfig tokenAuthConfig = EntraIDTokenAuthConfigBuilder.builder().clientId(testCtx.getClientId())
.secret(testCtx.getClientSecret()).authority(testCtx.getAuthority()).scopes(testCtx.getRedisScopes())
.expirationRefreshRatio(0.000001F).build();

// Configure timeout options to assure fast test failover
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(SocketOptions.builder().connectTimeout(Duration.ofSeconds(1)).build())
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(1)))
.reauthenticateBehavior(ClientOptions.ReauthenticateBehavior.ON_NEW_CREDENTIALS).build();

try (TokenBasedRedisCredentialsProvider credentialsProvider = new TokenBasedRedisCredentialsProvider(tokenAuthConfig)) {
RedisURI uri = RedisURI.builder().withHost(testCtx.host()).withPort(testCtx.port())
.withAuthentication(credentialsProvider).build();

try (RedisClient client = RedisClient.create(uri)) {
client.setOptions(clientOptions);

try (StatefulRedisConnection<String, String> connection = client.connect()) {

// Counter to track the number of command cycles
AtomicInteger commandCycleCount = new AtomicInteger(0);

// Start a thread to continuously send Redis commands
Thread commandThread = new Thread(() -> {
try {
RedisAsyncCommands<String, String> async = client.connect().async();
for (int i = 1; i <= 10; i++) {
// Start a transaction with SET and INCRBY commands
RedisFuture<String> multi = async.multi();
RedisFuture<String> set = async.set("key", "1");
RedisFuture<Long> incrby = async.incrby("key", 1);
RedisFuture<TransactionResult> exec = async.exec();
TransactionResult results = exec.get(1, TimeUnit.SECONDS);

// Increment the command cycle count after each execution
commandCycleCount.incrementAndGet();

// Verify the results from EXEC
assertThat(results).hasSize(2); // We expect 2 responses: SET and INCRBY

// Check the response from each command in the transaction
assertThat((String) results.get(0)).isEqualTo("OK"); // SET "key" = "1"
assertThat((Long) results.get(1)).isEqualTo(2L); // INCRBY "key" by 1, expected result is 2
}
} catch (Exception e) {
fail("Command execution failed during token refresh", e);
}
});

commandThread.start();

// Count token renewals directly within the main thread
AtomicInteger renewalCount = new AtomicInteger(0);
CountDownLatch latch = new CountDownLatch(10); // Wait for at least 10 token renewals

credentialsProvider.credentials().subscribe(cred -> {
latch.countDown(); // Signal each renewal as it's received
});

latch.await(1, TimeUnit.SECONDS); // Wait to reach 10 renewals
commandThread.join(); // Wait for the command thread to finish

// Verify that at least 10 command cycles were executed during the test
assertThat(commandCycleCount.get()).isGreaterThanOrEqualTo(10);
}
}
}
}

}
111 changes: 111 additions & 0 deletions src/test/java/io/lettuce/authx/EntraIdTestContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package io.lettuce.authx;

import io.github.cdimascio.dotenv.Dotenv;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class EntraIdTestContext {

private static final String AZURE_CLIENT_ID = "AZURE_CLIENT_ID";

private static final String AZURE_CLIENT_SECRET = "AZURE_CLIENT_SECRET";

private static final String AZURE_SP_OID = "AZURE_SP_OID";

private static final String AZURE_AUTHORITY = "AZURE_AUTHORITY";

private static final String AZURE_REDIS_SCOPES = "AZURE_REDIS_SCOPES";

private static final String REDIS_AZURE_HOST = "REDIS_AZURE_HOST";

private static final String REDIS_AZURE_PORT = "REDIS_AZURE_PORT";

private static final String REDIS_AZURE_CLUSTER_HOST = "REDIS_AZURE_CLUSTER_HOST";

private static final String REDIS_AZURE_CLUSTER_PORT = "REDIS_AZURE_CLUSTER_PORT";

private static final String REDIS_AZURE_DB = "REDIS_AZURE_DB";

private final String clientId;

private final String authority;

private final String clientSecret;

private final String spOID;

private final Set<String> redisScopes;

private final String redisHost;

private final int redisPort;

private final List<String> redisClusterHost;

private final int redisClusterPort;

private static Dotenv dotenv;
static {
dotenv = Dotenv.configure().directory("src/test/resources").filename(".env.entraid").load();
}

public static final EntraIdTestContext DEFAULT = new EntraIdTestContext();

private EntraIdTestContext() {
// Using Dotenv directly here
clientId = dotenv.get(AZURE_CLIENT_ID, "<client-id>");
clientSecret = dotenv.get(AZURE_CLIENT_SECRET, "<client-secret>");
spOID = dotenv.get(AZURE_SP_OID, "<service-provider-oid>");
authority = dotenv.get(AZURE_AUTHORITY, "https://login.microsoftonline.com/your-tenant-id");
redisHost = dotenv.get(REDIS_AZURE_HOST);
redisPort = Integer.parseInt(dotenv.get(REDIS_AZURE_PORT, "6379"));
redisClusterHost = Arrays.asList(dotenv.get(REDIS_AZURE_CLUSTER_HOST, "").split(","));
redisClusterPort = Integer.parseInt(dotenv.get(REDIS_AZURE_CLUSTER_PORT, "6379"));
String redisScopesEnv = dotenv.get(AZURE_REDIS_SCOPES, "https://redis.azure.com/.default");
if (redisScopesEnv != null && !redisScopesEnv.isEmpty()) {
this.redisScopes = new HashSet<>(Arrays.asList(redisScopesEnv.split(";")));
} else {
this.redisScopes = new HashSet<>();
}
}

public String host() {
return redisHost;
}

public int port() {
return redisPort;
}

public List<String> clusterHost() {
return redisClusterHost;
}

public int clusterPort() {
return redisClusterPort;
}

public String getClientId() {
return clientId;
}

public String getSpOID() {
return spOID;
}

public String getAuthority() {
return authority;
}

public String getClientSecret() {
return clientSecret;
}

public Set<String> getRedisScopes() {
return redisScopes;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public void shouldCompleteAllSubscribersOnStop() {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
credentialsProvider.shutdown();
credentialsProvider.close();
}).start();

StepVerifier.create(credentialsFlux1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ void tokenBasedCredentialProvider(RedisClient client) {
// verify that the connection is re-authenticated with the new user credentials
assertThat(connection.sync().aclWhoami()).isEqualTo("steave");

credentialsProvider.shutdown();
credentialsProvider.close();
connection.close();
client.removeListener(listener);
client.setOptions(
Expand Down
5 changes: 2 additions & 3 deletions src/test/java/io/lettuce/examples/TokenBasedAuthExample.java
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,8 @@ public static void main(String[] args) throws Exception {
// Shutdown Redis client and close connections
redisClusterClient.shutdown();
} finally {
credentialsUser1.shutdown();
credentialsUser2.shutdown();

credentialsUser1.close();
credentialsUser2.close();
}

}
Expand Down
11 changes: 11 additions & 0 deletions src/test/resources/.env.entraid
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
AZURE_SP_OID=<SERVICE_PROVIDER_OID>
AZURE_CLIENT_ID=<CLIENT_ID>
AZURE_CLIENT_SECRET=<CLIENT_SECRET>
AZURE_REDIS_SCOPES=https://redis.azure.com/.default
AZURE_AUTHORITY=https://login.microsoftonline.com/<TENANT_ID>
# Redis standalone db with Azure enabled authentication
REDIS_AZURE_HOST=
REDIS_AZURE_PORT=6379
# Redis cluster db with Azure enabled authentication & osscluster API enabled
REDIS_AZURE_CLUSTER_HOST=
REDIS_AZURE_CLUSTER_PORT=6379

0 comments on commit 00de1ce

Please sign in to comment.