This is an automated email from the ASF dual-hosted git repository.

quantranhong1999 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit abda009d82f6626b8f24f69656632c50e7fd873c
Author: Quan Tran <[email protected]>
AuthorDate: Thu Jul 2 16:23:23 2026 +0700

    JAMES-4195 Add Redis OIDC token cache for JMAP
    
    Adds the non-Guice Redis OIDC token cache implementation for JMAP, Redis 
key prefix customization, Redis command adapter, configuration parsing, and 
real Redis standalone/sentinel/cluster tests.
---
 .../redis/RedisReactiveCommandsFactory.java        |  88 ++++++++++
 .../backends/redis/RedisConfigurationUtils.scala   |  30 ++++
 server/pom.xml                                     |   1 +
 server/protocols/jmap-redis/pom.xml                | 107 ++++++++++++
 .../james/jmap/oidc/redis/RedisOidcTokenCache.java | 193 +++++++++++++++++++++
 .../redis/RedisOidcTokenCacheConfiguration.java    |  45 +++++
 .../oidc/redis/RedisOidcTokenCacheKeyPrefix.java   |  25 +++
 .../jmap/oidc/redis/RedisTokenCacheCommands.java   |  91 ++++++++++
 .../oidc/redis/RedisClusterOidcTokenCacheTest.java |  53 ++++++
 .../RedisOidcTokenCacheConfigurationTest.java      |  65 +++++++
 .../oidc/redis/RedisOidcTokenCacheContract.java    | 114 ++++++++++++
 .../redis/RedisSentinelOidcTokenCacheTest.java     |  45 +++++
 .../redis/RedisStandaloneOidcTokenCacheTest.java   |  48 +++++
 13 files changed, 905 insertions(+)

diff --git 
a/backends-common/redis/src/main/java/org/apache/james/backends/redis/RedisReactiveCommandsFactory.java
 
b/backends-common/redis/src/main/java/org/apache/james/backends/redis/RedisReactiveCommandsFactory.java
new file mode 100644
index 0000000000..803e454d64
--- /dev/null
+++ 
b/backends-common/redis/src/main/java/org/apache/james/backends/redis/RedisReactiveCommandsFactory.java
@@ -0,0 +1,88 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.backends.redis;
+
+import java.util.List;
+
+import jakarta.inject.Inject;
+
+import io.lettuce.core.AbstractRedisClient;
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.reactive.RedisReactiveCommands;
+import io.lettuce.core.cluster.RedisClusterClient;
+import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
+import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands;
+import io.lettuce.core.codec.StringCodec;
+import io.lettuce.core.masterreplica.MasterReplica;
+import io.lettuce.core.masterreplica.StatefulRedisMasterReplicaConnection;
+
+public class RedisReactiveCommandsFactory {
+    public interface CommandsFactory<T> {
+        T create(RedisReactiveCommands<String, String> commands);
+    }
+
+    public interface ClusterCommandsFactory<T> {
+        T create(RedisClusterReactiveCommands<String, String> commands);
+    }
+
+    private final RedisClientFactory redisClientFactory;
+    private final RedisConfiguration redisConfiguration;
+
+    @Inject
+    public RedisReactiveCommandsFactory(RedisClientFactory redisClientFactory, 
RedisConfiguration redisConfiguration) {
+        this.redisClientFactory = redisClientFactory;
+        this.redisConfiguration = redisConfiguration;
+    }
+
+    public <T> T create(CommandsFactory<T> commandsFactory, 
ClusterCommandsFactory<T> clusterCommandsFactory) {
+        AbstractRedisClient rawClient = redisClientFactory.rawRedisClient();
+
+        return switch (redisConfiguration) {
+            case StandaloneRedisConfiguration ignored ->
+                commandsFactory.create(((RedisClient) 
rawClient).connect(StringCodec.UTF8).reactive());
+
+            case ClusterRedisConfiguration clusterConfiguration -> {
+                RedisClusterClient client = (RedisClusterClient) rawClient;
+                StatefulRedisClusterConnection<String, String> connection = 
client.connect(StringCodec.UTF8);
+                connection.setReadFrom(clusterConfiguration.readFrom());
+                yield clusterCommandsFactory.create(connection.reactive());
+            }
+
+            case SentinelRedisConfiguration sentinelConfiguration -> {
+                StatefulRedisMasterReplicaConnection<String, String> 
connection = MasterReplica.connect(
+                    (RedisClient) rawClient, StringCodec.UTF8, 
sentinelConfiguration.redisURI());
+                connection.setReadFrom(sentinelConfiguration.readFrom());
+                yield commandsFactory.create(connection.reactive());
+            }
+
+            case MasterReplicaRedisConfiguration masterReplicaConfiguration -> 
{
+                List<RedisURI> uris = 
RedisConfigurationUtils$.MODULE$.asJavaRedisUris(masterReplicaConfiguration.redisURI());
+                StatefulRedisMasterReplicaConnection<String, String> 
connection = MasterReplica.connect(
+                    (RedisClient) rawClient, StringCodec.UTF8, uris);
+                connection.setReadFrom(masterReplicaConfiguration.readFrom());
+                yield commandsFactory.create(connection.reactive());
+            }
+
+            default ->
+                throw new RuntimeException("Unknown redis configuration type: 
" + redisConfiguration.getClass().getName());
+        };
+    }
+}
diff --git 
a/backends-common/redis/src/main/scala/org/apache/james/backends/redis/RedisConfigurationUtils.scala
 
b/backends-common/redis/src/main/scala/org/apache/james/backends/redis/RedisConfigurationUtils.scala
new file mode 100644
index 0000000000..abdd42cb08
--- /dev/null
+++ 
b/backends-common/redis/src/main/scala/org/apache/james/backends/redis/RedisConfigurationUtils.scala
@@ -0,0 +1,30 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License. You may obtain a copy of the License at    *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied. See the License for the     *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.backends.redis
+
+import io.lettuce.core.RedisURI
+import org.apache.james.backends.redis.RedisUris.RedisUris
+
+import scala.jdk.CollectionConverters._
+
+object RedisConfigurationUtils {
+  def asJavaRedisUris(redisURI: RedisUris): java.util.List[RedisURI] =
+    redisURI.value.asJava
+}
diff --git a/server/pom.xml b/server/pom.xml
index 43f55c0b86..b7226421a7 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -98,6 +98,7 @@
 
         <module>protocols/fetchmail</module>
         <module>protocols/jmap</module>
+        <module>protocols/jmap-redis</module>
         <module>protocols/jmap-rfc-8621</module>
         <module>protocols/jmap-rfc-8621-integration-tests</module>
         <module>protocols/jwt</module>
diff --git a/server/protocols/jmap-redis/pom.xml 
b/server/protocols/jmap-redis/pom.xml
new file mode 100644
index 0000000000..002ecae5d2
--- /dev/null
+++ b/server/protocols/jmap-redis/pom.xml
@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+    Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements. See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership. The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License. You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied. See the License for the
+    specific language governing permissions and limitations
+    under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
http://maven.apache.org/xsd/maven-4.0.0.xsd";>
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.james</groupId>
+        <artifactId>james-server</artifactId>
+        <version>3.10.0-SNAPSHOT</version>
+        <relativePath>../../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>james-server-jmap-redis</artifactId>
+
+    <name>Apache James :: Server :: JMAP Redis</name>
+    <description>Redis OIDC cache implementation for JMAP RFC8621</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>apache-james-backends-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>apache-james-backends-redis</artifactId>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-guice-common</artifactId>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-jmap</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-jmap</artifactId>
+            <version>${project.version}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>james-server-util</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>${james.groupId}</groupId>
+            <artifactId>testing-base</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>eu.timepit</groupId>
+            <artifactId>refined_${scala.base}</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>io.lettuce</groupId>
+            <artifactId>lettuce-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.projectreactor</groupId>
+            <artifactId>reactor-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-configuration2</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git 
a/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCache.java
 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCache.java
new file mode 100644
index 0000000000..ee740ced0b
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCache.java
@@ -0,0 +1,193 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.james.jmap.oidc.Aud;
+import org.apache.james.jmap.oidc.OidcTokenCache;
+import org.apache.james.jmap.oidc.OidcTokenCacheConfiguration;
+import org.apache.james.jmap.oidc.Sid;
+import org.apache.james.jmap.oidc.Token;
+import org.apache.james.jmap.oidc.TokenInfo;
+import org.apache.james.jmap.oidc.TokenInfoResolver;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.hash.Hashing;
+
+import io.lettuce.core.KeyValue;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+public class RedisOidcTokenCache implements OidcTokenCache {
+    public static class TokenParseException extends RuntimeException {
+        public TokenParseException(String message, Throwable cause) {
+            super(message, cause);
+        }
+
+        public TokenParseException(String message) {
+            super(message);
+        }
+    }
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(RedisOidcTokenCache.class);
+
+    private static final String AUD_DELIMITER = ";";
+
+    public interface TokenFields {
+        String EMAIL = "email";
+        String EXP = "exp";
+        String AUD = "aud";
+        String SID = "sid";
+    }
+
+    private final RedisTokenCacheCommands redisCommands;
+    private final TokenInfoResolver tokenInfoResolver;
+    private final OidcTokenCacheConfiguration tokenCacheConfiguration;
+    private final RedisOidcTokenCacheKeyPrefix keyPrefix;
+
+    public RedisOidcTokenCache(TokenInfoResolver tokenInfoResolver,
+                               OidcTokenCacheConfiguration 
oidcTokenCacheConfiguration,
+                               RedisTokenCacheCommands redisCommands,
+                               RedisOidcTokenCacheKeyPrefix keyPrefix) {
+        this.tokenInfoResolver = tokenInfoResolver;
+        this.tokenCacheConfiguration = oidcTokenCacheConfiguration;
+        this.redisCommands = redisCommands;
+        this.keyPrefix = keyPrefix;
+    }
+
+    @Override
+    public Mono<Void> invalidate(Sid sid) {
+        String sidRedisKey = resolveSidRedisKey(sid);
+        return redisCommands.lrange(sidRedisKey)
+            .collectList()
+            .filter(tokenRedisKeyList -> !tokenRedisKeyList.isEmpty())
+            .flatMap(tokenRedisKeyList -> 
redisCommands.del(tokenRedisKeyList.toArray(String[]::new)))
+            .then(redisCommands.del(sidRedisKey))
+            .then()
+            .onErrorResume(error -> {
+                LOGGER.warn("Failed to invalidate token cache for sid={}", 
sid.value(), error);
+                return Mono.empty();
+            });
+    }
+
+    @Override
+    public Mono<TokenInfo> associatedInformation(Token token) {
+        return Mono.fromCallable(() -> resolveTokenRedisKey(token))
+            .flatMap(tokenRedisKey -> getTokenInfoFromCache(tokenRedisKey)
+                .onErrorResume(error -> {
+                    LOGGER.warn("Failed to get token information from cache 
for redisKey={}", tokenRedisKey, error);
+                    return Mono.empty();
+                })
+                .switchIfEmpty(Mono.defer(() -> 
resolveTokenInfoAndCache(token, tokenRedisKey))));
+    }
+
+    public Mono<TokenInfo> getTokenInfoFromCache(String tokenRedisKey) {
+        return redisCommands.hgetall(tokenRedisKey)
+            .collectMap(KeyValue::getKey, KeyValue::getValue)
+            .filter(mapData -> !mapData.isEmpty())
+            .map(this::parseTokenInfo);
+    }
+
+    private TokenInfo parseTokenInfo(Map<String, String> mapData) throws 
TokenParseException {
+        String rawEmail = mapData.get(TokenFields.EMAIL);
+        String rawSid = mapData.get(TokenFields.SID);
+        String rawExp = mapData.get(TokenFields.EXP);
+        String rawAud = mapData.get(TokenFields.AUD);
+
+        if (StringUtils.isBlank(rawEmail) || StringUtils.isBlank(rawExp)) {
+            throw new TokenParseException("Missing required fields in token 
data: " + mapData);
+        }
+        try {
+            Optional<Sid> sid = Optional.ofNullable(rawSid)
+                .filter(StringUtils::isNotBlank)
+                .map(Sid::new);
+            Instant exp = Instant.ofEpochSecond(Long.parseLong(rawExp));
+
+            Optional<List<Aud>> maybeAudList = Optional.ofNullable(rawAud)
+                .filter(StringUtils::isNotBlank)
+                .map(value -> Splitter.on(AUD_DELIMITER)
+                    .omitEmptyStrings()
+                    .splitToStream(value)
+                    .map(Aud::new)
+                    .toList());
+
+            return new TokenInfo(rawEmail, sid, exp, maybeAudList);
+        } catch (Exception e) {
+            throw new TokenParseException("Failed to parse token fields from 
cache: " + mapData, e);
+        }
+    }
+
+    private Mono<TokenInfo> resolveTokenInfoAndCache(Token token, String 
tokenRedisKey) {
+        return tokenInfoResolver.apply(token)
+            .publishOn(Schedulers.parallel())
+            .flatMap(tokenInfo -> cacheTokenInfo(tokenRedisKey, tokenInfo)
+                .thenReturn(tokenInfo));
+    }
+
+    private Mono<Void> cacheTokenInfo(String tokenRedisKey, TokenInfo 
tokenInfo) {
+        return cacheAssociatedInformation(tokenRedisKey, tokenInfo)
+            .then(Mono.justOrEmpty(tokenInfo.sid())
+                .flatMap(sidValue -> cacheSidInfo(sidValue, tokenRedisKey)))
+            .then()
+            .onErrorResume(error -> {
+                LOGGER.warn("Failed to cache token info: {}", 
tokenInfo.asString(), error);
+                return Mono.empty();
+            });
+    }
+
+    private Mono<Void> cacheAssociatedInformation(String tokenRedisKey, 
TokenInfo tokenInfo) {
+        Map<String, String> mapData = ImmutableMap.of(
+            TokenFields.EMAIL, tokenInfo.email(),
+            TokenFields.EXP, String.valueOf(tokenInfo.exp().getEpochSecond()),
+            TokenFields.AUD, tokenInfo.aud()
+                .map(audList -> 
Joiner.on(AUD_DELIMITER).skipNulls().join(Lists.transform(audList, Aud::value)))
+                .orElse(StringUtils.EMPTY),
+            TokenFields.SID, 
tokenInfo.sid().map(Sid::value).orElse(StringUtils.EMPTY));
+
+        return redisCommands.hset(tokenRedisKey, mapData)
+            .then(redisCommands.expire(tokenRedisKey, 
tokenCacheConfiguration.expiration()));
+    }
+
+    private Mono<String> cacheSidInfo(Sid sid, String tokenRedisKey) {
+        return Mono.fromCallable(() -> resolveSidRedisKey(sid))
+            .flatMap(sidRedisKey -> redisCommands.rpush(sidRedisKey, 
tokenRedisKey)
+                .then(redisCommands.expire(sidRedisKey, 
tokenCacheConfiguration.expiration()))
+                .thenReturn(sidRedisKey));
+    }
+
+    private String resolveTokenRedisKey(Token token) {
+        return keyPrefix.tokenPrefix() + 
Hashing.sha512().hashString(token.value(), StandardCharsets.UTF_8);
+    }
+
+    private String resolveSidRedisKey(Sid sid) {
+        return keyPrefix.sidPrefix() + sid.value();
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheConfiguration.java
 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheConfiguration.java
new file mode 100644
index 0000000000..f8c91fe30d
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheConfiguration.java
@@ -0,0 +1,45 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import java.time.Duration;
+import java.util.Optional;
+
+import org.apache.commons.configuration2.Configuration;
+import org.apache.james.util.DurationParser;
+
+import com.google.common.base.Preconditions;
+
+public record RedisOidcTokenCacheConfiguration(Duration commandTimeout) {
+    public static final String COMMAND_TIMEOUT_PROPERTY = 
"oidc.token.cache.redis.command.timeout";
+    public static final Duration DEFAULT_COMMAND_TIMEOUT = 
Duration.ofSeconds(3);
+    public static final RedisOidcTokenCacheConfiguration DEFAULT = new 
RedisOidcTokenCacheConfiguration(DEFAULT_COMMAND_TIMEOUT);
+
+    public static RedisOidcTokenCacheConfiguration from(Configuration 
configuration) {
+        return new RedisOidcTokenCacheConfiguration(
+            
Optional.ofNullable(configuration.getString(COMMAND_TIMEOUT_PROPERTY, null))
+                .map(DurationParser::parse)
+                .orElse(DEFAULT_COMMAND_TIMEOUT));
+    }
+
+    public RedisOidcTokenCacheConfiguration {
+        Preconditions.checkArgument(!commandTimeout.isZero() && 
!commandTimeout.isNegative(), "commandTimeout must be positive");
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheKeyPrefix.java
 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheKeyPrefix.java
new file mode 100644
index 0000000000..70508d0fd3
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheKeyPrefix.java
@@ -0,0 +1,25 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+public record RedisOidcTokenCacheKeyPrefix(String tokenPrefix, String 
sidPrefix) {
+    public static final RedisOidcTokenCacheKeyPrefix JAMES_DEFAULT =
+        new RedisOidcTokenCacheKeyPrefix("james_oidc_hash_", 
"james_oidc_sid_");
+}
diff --git 
a/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisTokenCacheCommands.java
 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisTokenCacheCommands.java
new file mode 100644
index 0000000000..c81c07dcec
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/main/java/org/apache/james/jmap/oidc/redis/RedisTokenCacheCommands.java
@@ -0,0 +1,91 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import java.time.Duration;
+import java.util.Map;
+
+import io.lettuce.core.KeyValue;
+import io.lettuce.core.api.reactive.RedisHashReactiveCommands;
+import io.lettuce.core.api.reactive.RedisKeyReactiveCommands;
+import io.lettuce.core.api.reactive.RedisListReactiveCommands;
+import io.lettuce.core.api.reactive.RedisReactiveCommands;
+import io.lettuce.core.cluster.api.reactive.RedisClusterReactiveCommands;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+public class RedisTokenCacheCommands {
+
+    public static RedisTokenCacheCommands of(RedisReactiveCommands<String, 
String> commands, Duration commandTimeout) {
+        return new RedisTokenCacheCommands(commands, commands, commands, 
commandTimeout);
+    }
+
+    public static RedisTokenCacheCommands 
of(RedisClusterReactiveCommands<String, String> commands, Duration 
commandTimeout) {
+        return new RedisTokenCacheCommands(commands, commands, commands, 
commandTimeout);
+    }
+
+    private final RedisKeyReactiveCommands<String, String> keyCommand;
+    private final RedisListReactiveCommands<String, String> listCommand;
+    private final RedisHashReactiveCommands<String, String> hashCommand;
+    private final Duration commandTimeout;
+
+    public RedisTokenCacheCommands(RedisKeyReactiveCommands<String, String> 
keyCommand,
+                                   RedisListReactiveCommands<String, String> 
listCommand,
+                                   RedisHashReactiveCommands<String, String> 
hashCommand,
+                                   Duration commandTimeout) {
+        this.keyCommand = keyCommand;
+        this.listCommand = listCommand;
+        this.hashCommand = hashCommand;
+        this.commandTimeout = commandTimeout;
+    }
+
+    public Flux<String> lrange(String key) {
+        return listCommand.lrange(key, 0, -1)
+            .timeout(commandTimeout);
+    }
+
+    public Mono<Long> rpush(String key, String... values) {
+        return listCommand.rpush(key, values)
+            .timeout(commandTimeout);
+    }
+
+    public Mono<Void> del(String... key) {
+        return keyCommand.del(key)
+            .timeout(commandTimeout)
+            .then();
+    }
+
+    public Mono<Void> expire(String key, Duration duration) {
+        return keyCommand.expire(key, duration)
+            .timeout(commandTimeout)
+            .then();
+    }
+
+    public Mono<Void> hset(String key, Map<String, String> map) {
+        return hashCommand.hset(key, map)
+            .timeout(commandTimeout)
+            .then();
+    }
+
+    public Flux<KeyValue<String, String>> hgetall(String key) {
+        return hashCommand.hgetall(key)
+            .timeout(commandTimeout);
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisClusterOidcTokenCacheTest.java
 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisClusterOidcTokenCacheTest.java
new file mode 100644
index 0000000000..12ddf6c329
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisClusterOidcTokenCacheTest.java
@@ -0,0 +1,53 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import org.apache.james.backends.redis.RedisClusterExtension;
+import org.apache.james.backends.redis.RedisConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class RedisClusterOidcTokenCacheTest extends 
RedisOidcTokenCacheContract {
+    @RegisterExtension
+    static RedisClusterExtension redisClusterExtension = new 
RedisClusterExtension();
+
+    private static RedisClusterExtension.RedisClusterContainer 
redisClusterContainer;
+
+    @BeforeAll
+    static void setUp(RedisClusterExtension.RedisClusterContainer container) {
+        redisClusterContainer = container;
+    }
+
+    @AfterEach
+    void tearDown() {
+        redisClusterContainer.unPauseOne();
+    }
+
+    @Override
+    public RedisConfiguration redisConfiguration() {
+        return redisClusterContainer.getRedisConfiguration();
+    }
+
+    @Override
+    public void pauseRedis() {
+        redisClusterContainer.pauseOne();
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheConfigurationTest.java
 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheConfigurationTest.java
new file mode 100644
index 0000000000..8b26c89c67
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheConfigurationTest.java
@@ -0,0 +1,65 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Duration;
+
+import org.apache.commons.configuration2.PropertiesConfiguration;
+import org.junit.jupiter.api.Test;
+
+class RedisOidcTokenCacheConfigurationTest {
+    @Test
+    void shouldDefaultCommandTimeout() {
+        PropertiesConfiguration configuration = new PropertiesConfiguration();
+
+        
assertThat(RedisOidcTokenCacheConfiguration.from(configuration).commandTimeout())
+            .isEqualTo(Duration.ofSeconds(3));
+    }
+
+    @Test
+    void shouldParseDuration() {
+        PropertiesConfiguration configuration = new PropertiesConfiguration();
+        
configuration.addProperty(RedisOidcTokenCacheConfiguration.COMMAND_TIMEOUT_PROPERTY,
 "500ms");
+
+        
assertThat(RedisOidcTokenCacheConfiguration.from(configuration).commandTimeout())
+            .isEqualTo(Duration.ofMillis(500));
+    }
+
+    @Test
+    void shouldRejectInvalidCommandTimeout() {
+        PropertiesConfiguration configuration = new PropertiesConfiguration();
+        
configuration.addProperty(RedisOidcTokenCacheConfiguration.COMMAND_TIMEOUT_PROPERTY,
 "invalid");
+
+        assertThatThrownBy(() -> 
RedisOidcTokenCacheConfiguration.from(configuration))
+            .isInstanceOf(NumberFormatException.class);
+    }
+
+    @Test
+    void shouldRejectZeroCommandTimeout() {
+        PropertiesConfiguration configuration = new PropertiesConfiguration();
+        
configuration.addProperty(RedisOidcTokenCacheConfiguration.COMMAND_TIMEOUT_PROPERTY,
 "0seconds");
+
+        assertThatThrownBy(() -> 
RedisOidcTokenCacheConfiguration.from(configuration))
+            .isInstanceOf(IllegalArgumentException.class);
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheContract.java
 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheContract.java
new file mode 100644
index 0000000000..e9d08ea6b8
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisOidcTokenCacheContract.java
@@ -0,0 +1,114 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.james.backends.redis.RedisClientFactory;
+import org.apache.james.backends.redis.RedisConfiguration;
+import org.apache.james.backends.redis.RedisReactiveCommandsFactory;
+import org.apache.james.core.Username;
+import org.apache.james.jmap.oidc.OidcTokenCache;
+import org.apache.james.jmap.oidc.OidcTokenCacheConfiguration;
+import org.apache.james.jmap.oidc.OidcTokenCacheContract;
+import org.apache.james.jmap.oidc.Token;
+import org.apache.james.jmap.oidc.TokenInfo;
+import org.apache.james.server.core.filesystem.FileSystemImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import com.google.common.hash.Hashing;
+
+public abstract class RedisOidcTokenCacheContract extends 
OidcTokenCacheContract {
+    public abstract void pauseRedis();
+
+    private RedisOidcTokenCache redisOidcTokenCache;
+
+    @BeforeEach
+    void setUp() {
+        token = newToken();
+        token2 = newToken();
+        mockTokenInfoResolverSuccess(token, TOKEN_INFO);
+
+        RedisConfiguration redisConfiguration = redisConfiguration();
+        RedisClientFactory redisClientFactory = new 
RedisClientFactory(FileSystemImpl.forTesting(), redisConfiguration);
+        RedisReactiveCommandsFactory redisReactiveCommandsFactory = new 
RedisReactiveCommandsFactory(redisClientFactory, redisConfiguration);
+        RedisTokenCacheCommands redisReactiveCommands = 
redisReactiveCommandsFactory.create(
+            commands -> RedisTokenCacheCommands.of(commands, 
RedisOidcTokenCacheConfiguration.DEFAULT.commandTimeout()),
+            commands -> RedisTokenCacheCommands.of(commands, 
RedisOidcTokenCacheConfiguration.DEFAULT.commandTimeout()));
+        redisOidcTokenCache = new RedisOidcTokenCache(tokenInfoResolver,
+            OidcTokenCacheConfiguration.DEFAULT, redisReactiveCommands, 
RedisOidcTokenCacheKeyPrefix.JAMES_DEFAULT);
+    }
+
+    @Override
+    public OidcTokenCache testee() {
+        return redisOidcTokenCache;
+    }
+
+    public abstract RedisConfiguration redisConfiguration();
+
+    @Override
+    public Optional<Username> getUsernameFromCache(Token token) {
+        return redisOidcTokenCache.getTokenInfoFromCache("james_oidc_hash_" + 
Hashing.sha512().hashString(token.value(), StandardCharsets.UTF_8))
+            .map(TokenInfo::email)
+            .map(Username::of)
+            .blockOptional();
+    }
+
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void associatedUsernameShouldStillReturnValueWhenRedisIsDown() {
+        mockTokenInfoResolverSuccess(token, TOKEN_INFO);
+
+        pauseRedis();
+
+        assertThat(testee().associatedInformation(token).block())
+            .isEqualTo(TOKEN_INFO);
+    }
+
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void invalidateShouldSwallowRedisError() {
+        pauseRedis();
+        assertThatCode(() -> testee().invalidate(SID).block())
+            .doesNotThrowAnyException();
+    }
+
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    @Test
+    public void shouldFallbackGracefullyWhenRedisIsDown() {
+        mockTokenInfoResolverSuccess(token, TOKEN_INFO);
+        testee().associatedInformation(token).block();
+
+        pauseRedis();
+
+        assertThatCode(() -> testee().invalidate(SID).block())
+            .doesNotThrowAnyException();
+
+        assertThat(testee().associatedInformation(token).block())
+            .isEqualTo(TOKEN_INFO);
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisSentinelOidcTokenCacheTest.java
 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisSentinelOidcTokenCacheTest.java
new file mode 100644
index 0000000000..5a63710ef0
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisSentinelOidcTokenCacheTest.java
@@ -0,0 +1,45 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import org.apache.james.backends.redis.RedisConfiguration;
+import org.apache.james.backends.redis.RedisSentinelExtension;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class RedisSentinelOidcTokenCacheTest extends 
RedisOidcTokenCacheContract {
+    @RegisterExtension
+    static RedisSentinelExtension redisSentinelExtension = new 
RedisSentinelExtension();
+
+    @AfterEach
+    void tearDown() {
+        
redisSentinelExtension.getRedisSentinelCluster().redisMasterReplicaContainerList().unPauseMasterNode();
+    }
+
+    @Override
+    public RedisConfiguration redisConfiguration() {
+        return 
redisSentinelExtension.getRedisSentinelCluster().redisSentinelContainerList().getRedisConfiguration();
+    }
+
+    @Override
+    public void pauseRedis() {
+        
redisSentinelExtension.getRedisSentinelCluster().redisMasterReplicaContainerList().pauseMasterNode();
+    }
+}
diff --git 
a/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisStandaloneOidcTokenCacheTest.java
 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisStandaloneOidcTokenCacheTest.java
new file mode 100644
index 0000000000..f1e53d214a
--- /dev/null
+++ 
b/server/protocols/jmap-redis/src/test/java/org/apache/james/jmap/oidc/redis/RedisStandaloneOidcTokenCacheTest.java
@@ -0,0 +1,48 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.oidc.redis;
+
+import org.apache.james.backends.redis.RedisConfiguration;
+import org.apache.james.backends.redis.RedisExtension;
+import org.apache.james.backends.redis.StandaloneRedisConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class RedisStandaloneOidcTokenCacheTest extends 
RedisOidcTokenCacheContract {
+    @RegisterExtension
+    static RedisExtension redisExtension = new RedisExtension();
+
+    @AfterEach
+    void tearDown() {
+        if (redisExtension.dockerRedis().isPaused()) {
+            redisExtension.dockerRedis().unPause();
+        }
+    }
+
+    @Override
+    public RedisConfiguration redisConfiguration() {
+        return 
StandaloneRedisConfiguration.from(redisExtension.dockerRedis().redisURI().toString());
+    }
+
+    @Override
+    public void pauseRedis() {
+        redisExtension.dockerRedis().pause();
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to