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]
