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 d432868795d16163ecb6db8f10c92e2cec80d408
Author: Quan Tran <[email protected]>
AuthorDate: Thu Jul 2 16:13:14 2026 +0700

    JAMES-4195 Add JMAP OIDC token cache abstraction
    
    Adds OIDC token value objects, cache configuration, cache interface, 
Caffeine cache implementation, and cache contract tests.
---
 server/protocols/jmap/pom.xml                      |  17 ++
 .../main/java/org/apache/james/jmap/oidc/Aud.java  |  23 +++
 .../james/jmap/oidc/CaffeineOidcTokenCache.java    |  96 +++++++++
 .../org/apache/james/jmap/oidc/OidcTokenCache.java |  28 +++
 .../jmap/oidc/OidcTokenCacheConfiguration.java     |  49 +++++
 .../main/java/org/apache/james/jmap/oidc/Sid.java  |  23 +++
 .../java/org/apache/james/jmap/oidc/Token.java     |  23 +++
 .../java/org/apache/james/jmap/oidc/TokenInfo.java |  42 ++++
 .../apache/james/jmap/oidc/TokenInfoResolver.java  |  27 +++
 .../jmap/oidc/CaffeineOidcTokenCacheTest.java      |  44 ++++
 .../james/jmap/oidc/OidcTokenCacheContract.java    | 221 +++++++++++++++++++++
 11 files changed, 593 insertions(+)

diff --git a/server/protocols/jmap/pom.xml b/server/protocols/jmap/pom.xml
index 7854c215ff..32f57a72f5 100644
--- a/server/protocols/jmap/pom.xml
+++ b/server/protocols/jmap/pom.xml
@@ -68,6 +68,10 @@
             <artifactId>testing-base</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+        </dependency>
         <dependency>
             <groupId>com.google.guava</groupId>
             <artifactId>guava</artifactId>
@@ -80,6 +84,10 @@
             <groupId>io.micrometer</groupId>
             <artifactId>micrometer-tracing</artifactId>
         </dependency>
+        <dependency>
+            <groupId>io.projectreactor</groupId>
+            <artifactId>reactor-core</artifactId>
+        </dependency>
         <dependency>
             <groupId>io.projectreactor.netty</groupId>
             <artifactId>reactor-netty</artifactId>
@@ -93,10 +101,19 @@
             <groupId>jakarta.annotation</groupId>
             <artifactId>jakarta.annotation-api</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-configuration2</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpcore</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>jcl-over-slf4j</artifactId>
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Aud.java 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Aud.java
new file mode 100644
index 0000000000..5037dbb143
--- /dev/null
+++ b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Aud.java
@@ -0,0 +1,23 @@
+/****************************************************************
+ * 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;
+
+public record Aud(String value) {
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/CaffeineOidcTokenCache.java
 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/CaffeineOidcTokenCache.java
new file mode 100644
index 0000000000..eea435fc69
--- /dev/null
+++ 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/CaffeineOidcTokenCache.java
@@ -0,0 +1,96 @@
+/****************************************************************
+ * 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;
+
+import java.util.List;
+import java.util.Optional;
+
+import jakarta.inject.Inject;
+
+import org.apache.james.core.Username;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.AsyncCacheLoader;
+import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimaps;
+import com.google.common.collect.SetMultimap;
+
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+public class CaffeineOidcTokenCache implements OidcTokenCache {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(CaffeineOidcTokenCache.class);
+    private static final long DEFAULT_TOKEN_CACHE_MAX_SIZE = 10_000;
+
+    private final AsyncLoadingCache<Token, TokenInfo> cacheToken;
+    private final SetMultimap<Sid, Token> sidToTokens = 
Multimaps.synchronizedSetMultimap(HashMultimap.create());
+
+    @Inject
+    public CaffeineOidcTokenCache(TokenInfoResolver tokenInfoResolver, 
OidcTokenCacheConfiguration configuration) {
+        AsyncCacheLoader<Token, TokenInfo> cacheLoader = (token, executor) -> 
tokenInfoResolver.apply(token)
+            .map(tokenInfo -> {
+                tokenInfo.sid().ifPresentOrElse(sidValue -> 
sidToTokens.put(sidValue, token),
+                    () -> LOGGER.warn("OIDC token of user {} does not have a 
sid, this will break backchannel logout. Please review OIDC configuration.", 
tokenInfo.email()));
+                return tokenInfo;
+            })
+            .subscribeOn(Schedulers.fromExecutor(executor))
+            .toFuture();
+
+        cacheToken = Caffeine.newBuilder()
+            .expireAfterWrite(configuration.expiration())
+            
.maximumSize(configuration.tokenCacheMaxSize().orElse(DEFAULT_TOKEN_CACHE_MAX_SIZE))
+            .removalListener((Token token, TokenInfo tokenInfo, RemovalCause 
cause) -> {
+                if (cause.wasEvicted() && tokenInfo != null) {
+                    tokenInfo.sid().ifPresent(sid -> sidToTokens.remove(sid, 
token));
+                }
+            })
+            .buildAsync(cacheLoader);
+    }
+
+    @Override
+    public Mono<Void> invalidate(Sid sid) {
+        List<Token> snapshot;
+        synchronized (sidToTokens) {
+            snapshot = List.copyOf(sidToTokens.get(sid));
+        }
+        return Mono.fromRunnable(() -> 
cacheToken.synchronous().invalidateAll(snapshot))
+            .subscribeOn(Schedulers.boundedElastic())
+            .then(Mono.fromRunnable(() -> sidToTokens.removeAll(sid)))
+            .then();
+    }
+
+    @Override
+    public Mono<TokenInfo> associatedInformation(Token token) {
+        return Mono.fromFuture(cacheToken.get(token));
+    }
+
+    @VisibleForTesting
+    Optional<Username> getUsernameFromCache(Token token) {
+        return 
Optional.ofNullable(cacheToken.synchronous().getIfPresent(token))
+            .map(TokenInfo::email)
+            .map(Username::of);
+    }
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/OidcTokenCache.java
 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/OidcTokenCache.java
new file mode 100644
index 0000000000..84eb7a7150
--- /dev/null
+++ 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/OidcTokenCache.java
@@ -0,0 +1,28 @@
+/****************************************************************
+ * 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;
+
+import reactor.core.publisher.Mono;
+
+public interface OidcTokenCache {
+    Mono<Void> invalidate(Sid sid);
+
+    Mono<TokenInfo> associatedInformation(Token token);
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/OidcTokenCacheConfiguration.java
 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/OidcTokenCacheConfiguration.java
new file mode 100644
index 0000000000..cd69435139
--- /dev/null
+++ 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/OidcTokenCacheConfiguration.java
@@ -0,0 +1,49 @@
+/****************************************************************
+ * 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;
+
+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 OidcTokenCacheConfiguration(Duration expiration, Optional<Long> 
tokenCacheMaxSize) {
+
+    public static final String OIDC_TOKEN_CACHE_EXPIRATION = 
"oidc.token.cache.expiration";
+    public static final String OIDC_TOKEN_CACHE_MAX_SIZE = 
"oidc.token.cache.maxSize";
+    public static final Duration DEFAULT_EXPIRATION = Duration.ofMinutes(5);
+
+    public static final OidcTokenCacheConfiguration DEFAULT  = new 
OidcTokenCacheConfiguration(DEFAULT_EXPIRATION, Optional.empty());
+
+    public static OidcTokenCacheConfiguration parse(Configuration 
configuration) {
+        Optional<Long> tokenCacheMaxSize = 
Optional.ofNullable(configuration.getString(OIDC_TOKEN_CACHE_MAX_SIZE, null))
+            .map(Long::parseLong);
+
+        return new 
OidcTokenCacheConfiguration(Optional.ofNullable(configuration.getString(OIDC_TOKEN_CACHE_EXPIRATION))
+            .map(DurationParser::parse).orElse(DEFAULT_EXPIRATION), 
tokenCacheMaxSize);
+    }
+
+    public OidcTokenCacheConfiguration {
+        tokenCacheMaxSize.ifPresent(maxSize -> 
Preconditions.checkArgument(maxSize > 0, "maxSize must be greater than 0"));
+    }
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Sid.java 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Sid.java
new file mode 100644
index 0000000000..06749a746c
--- /dev/null
+++ b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Sid.java
@@ -0,0 +1,23 @@
+/****************************************************************
+ * 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;
+
+public record Sid(String value) {
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Token.java 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Token.java
new file mode 100644
index 0000000000..548214d0d7
--- /dev/null
+++ b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/Token.java
@@ -0,0 +1,23 @@
+/****************************************************************
+ * 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;
+
+public record Token(String value) {
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/TokenInfo.java 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/TokenInfo.java
new file mode 100644
index 0000000000..ad2cb8d855
--- /dev/null
+++ 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/TokenInfo.java
@@ -0,0 +1,42 @@
+/****************************************************************
+ * 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;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import com.google.common.base.MoreObjects;
+
+public record TokenInfo(String email, Optional<Sid> sid, Instant exp, 
Optional<List<Aud>> aud) {
+
+    public String asString() {
+        return MoreObjects.toStringHelper(this)
+            .add("email", email)
+            .add("sid", Optional.ofNullable(sid).flatMap(sidValue -> 
sidValue.map(Sid::value)).orElse(null))
+            .add("exp", exp)
+            .add("aud", aud.map(audList -> audList
+                    .stream()
+                    .map(Aud::value)
+                    .toList())
+                .orElse(null))
+            .toString();
+    }
+}
diff --git 
a/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/TokenInfoResolver.java
 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/TokenInfoResolver.java
new file mode 100644
index 0000000000..ebb885a5b0
--- /dev/null
+++ 
b/server/protocols/jmap/src/main/java/org/apache/james/jmap/oidc/TokenInfoResolver.java
@@ -0,0 +1,27 @@
+/****************************************************************
+ * 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;
+
+import java.util.function.Function;
+
+import reactor.core.publisher.Mono;
+
+public interface TokenInfoResolver extends Function<Token, Mono<TokenInfo>> {
+}
diff --git 
a/server/protocols/jmap/src/test/java/org/apache/james/jmap/oidc/CaffeineOidcTokenCacheTest.java
 
b/server/protocols/jmap/src/test/java/org/apache/james/jmap/oidc/CaffeineOidcTokenCacheTest.java
new file mode 100644
index 0000000000..12d23c6956
--- /dev/null
+++ 
b/server/protocols/jmap/src/test/java/org/apache/james/jmap/oidc/CaffeineOidcTokenCacheTest.java
@@ -0,0 +1,44 @@
+/****************************************************************
+ * 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;
+
+import java.util.Optional;
+
+import org.apache.james.core.Username;
+import org.junit.jupiter.api.BeforeEach;
+
+public class CaffeineOidcTokenCacheTest extends OidcTokenCacheContract {
+    private CaffeineOidcTokenCache testee;
+
+    @BeforeEach
+    void setUp() {
+        testee = new CaffeineOidcTokenCache(tokenInfoResolver, 
OidcTokenCacheConfiguration.DEFAULT);
+    }
+
+    @Override
+    public CaffeineOidcTokenCache testee() {
+        return testee;
+    }
+
+    @Override
+    public Optional<Username> getUsernameFromCache(Token token) {
+        return testee.getUsernameFromCache(token);
+    }
+}
diff --git 
a/server/protocols/jmap/src/test/java/org/apache/james/jmap/oidc/OidcTokenCacheContract.java
 
b/server/protocols/jmap/src/test/java/org/apache/james/jmap/oidc/OidcTokenCacheContract.java
new file mode 100644
index 0000000000..644592371d
--- /dev/null
+++ 
b/server/protocols/jmap/src/test/java/org/apache/james/jmap/oidc/OidcTokenCacheContract.java
@@ -0,0 +1,221 @@
+/****************************************************************
+ * 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;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.apache.james.core.Username;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.google.common.collect.ImmutableList;
+
+import reactor.core.publisher.Mono;
+
+public abstract class OidcTokenCacheContract {
+    protected static final String EMAIL = "[email protected]";
+    protected static final String SID_STRING = "sid-1";
+    protected static final Sid SID = new Sid(SID_STRING);
+    protected static final Optional<List<Aud>> AUD = 
Optional.of(ImmutableList.of(new Aud("james")));
+    protected static final Instant EXPIRES_AT = 
Instant.now().plus(Duration.ofMinutes(1)).truncatedTo(ChronoUnit.SECONDS);
+    protected static final TokenInfo TOKEN_INFO = new TokenInfo(EMAIL, 
Optional.of(SID), EXPIRES_AT, AUD);
+
+    protected static final String EMAIL_2 = "[email protected]";
+    protected static final String SID_STRING_2 = "sid-2";
+    protected static final Sid SID_2 = new Sid(SID_STRING_2);
+    protected static final TokenInfo TOKEN_INFO_2 = new TokenInfo(EMAIL_2, 
Optional.of(SID_2), EXPIRES_AT, AUD);
+
+    protected TokenInfoResolver tokenInfoResolver = 
mock(TokenInfoResolver.class);
+
+    protected Token token;
+    protected Token token2;
+
+    public abstract OidcTokenCache testee();
+
+    public abstract Optional<Username> getUsernameFromCache(Token token);
+
+    @BeforeEach
+    void beforeEach() {
+        token = newToken();
+        token2 = newToken();
+        mockTokenInfoResolverSuccess(token, TOKEN_INFO);
+    }
+
+    @AfterEach
+    void afterEach() {
+        reset(tokenInfoResolver);
+    }
+
+    protected Token newToken() {
+        return new Token("token-" + UUID.randomUUID());
+    }
+
+    @Test
+    void invalidateShouldRemoveSidFromCache() {
+        testee().associatedInformation(token).block();
+
+        assertThat(getUsernameFromCache(token)).contains(Username.of(EMAIL));
+
+        testee().invalidate(SID).block();
+        assertThat(getUsernameFromCache(token)).isEmpty();
+    }
+
+    @Test
+    void invalidateShouldRemoveTokenFromCache() {
+        testee().associatedInformation(token).block();
+        verify(tokenInfoResolver, times(1)).apply(token);
+        testee().invalidate(SID).block();
+
+        testee().associatedInformation(token).block();
+        verify(tokenInfoResolver, times(2)).apply(token);
+    }
+
+    @Test
+    void invalidateShouldRemoveAllTokensForSid() {
+        TokenInfo tokenInfo1 = new TokenInfo(EMAIL, Optional.of(SID), 
EXPIRES_AT, AUD);
+        TokenInfo tokenInfo2 = new TokenInfo(EMAIL_2, Optional.of(SID), 
EXPIRES_AT, AUD);
+
+        mockTokenInfoResolverSuccess(token, tokenInfo1);
+        mockTokenInfoResolverSuccess(token2, tokenInfo2);
+
+        testee().associatedInformation(token).block();
+        testee().associatedInformation(token2).block();
+
+        testee().invalidate(SID).block();
+
+        assertThat(getUsernameFromCache(token)).isEmpty();
+        assertThat(getUsernameFromCache(token2)).isEmpty();
+    }
+
+    @Test
+    void invalidateShouldNotThrowWhenSidNotCached() {
+        assertThatCode(() -> testee().invalidate(new 
Sid(UUID.randomUUID().toString())).block())
+            .doesNotThrowAnyException();
+    }
+
+    @Test
+    void invalidateShouldNotAffectOtherTokens() {
+        mockTokenInfoResolverSuccess(token, TOKEN_INFO);
+        mockTokenInfoResolverSuccess(token2, TOKEN_INFO_2);
+        testee().associatedInformation(token).block();
+        testee().associatedInformation(token2).block();
+
+        verify(tokenInfoResolver, times(1)).apply(token2);
+
+        testee().invalidate(SID).block();
+
+        testee().associatedInformation(token2).block();
+        verify(tokenInfoResolver, times(1)).apply(token2);
+    }
+
+    @Test
+    void associatedUsernameShouldReturnUsername() {
+        assertThat(testee().associatedInformation(token).block().email())
+            .isEqualTo(EMAIL);
+    }
+
+    @Test
+    void associatedUsernameShouldThrowErrorWhenTokenCouldNotBeResolved() {
+        Token token = new Token("token-" + UUID.randomUUID());
+        mockTokenInfoResolverFailure(token, new RuntimeException("Token not 
found"));
+
+        assertThatThrownBy(() -> testee().associatedInformation(token).block())
+            .hasMessage("Token not found");
+    }
+
+    @Test
+    void associatedUsernameShouldPopulateCache() {
+        testee().associatedInformation(token).block();
+        assertThat(getUsernameFromCache(token))
+            .contains(Username.of(EMAIL));
+    }
+
+    @Test
+    void associatedUsernameShouldNotPopulateCacheWhenCacheHit() {
+        for (int i = 0; i < 5; i++) {
+            testee().associatedInformation(token).block();
+        }
+        verify(tokenInfoResolver, times(1)).apply(token);
+    }
+
+    @Test
+    void associatedUsernameShouldThrowWhenInvalidateRelatedSid() {
+        Token token = new Token("token-" + UUID.randomUUID());
+        mockTokenInfoResolverSuccess(token, TOKEN_INFO);
+        testee().associatedInformation(token).block();
+        testee().invalidate(SID).block();
+
+        mockTokenInfoResolverFailure(token, new RuntimeException("Token 
expired"));
+
+        assertThatThrownBy(() -> testee().associatedInformation(token).block())
+            .hasMessage("Token expired");
+    }
+
+    @Test
+    void associatedUsernameShouldCachedWhenAbsentSidInTokenInfo() {
+        Token token = new Token("token-" + UUID.randomUUID());
+        mockTokenInfoResolverSuccess(token, new TokenInfo(EMAIL, 
Optional.empty(), EXPIRES_AT, AUD));
+
+        for (int i = 0; i < 5; i++) {
+            testee().associatedInformation(token).block();
+        }
+        verify(tokenInfoResolver, times(1)).apply(token);
+        assertThat(getUsernameFromCache(token)).contains(Username.of(EMAIL));
+    }
+
+    @Test
+    void associatedUsernameShouldBeCachedWhenAbsentAudInTokenInfo() {
+        Token token = new Token("token-" + UUID.randomUUID());
+        mockTokenInfoResolverSuccess(token, new TokenInfo(EMAIL, 
Optional.of(SID), EXPIRES_AT, Optional.empty()));
+
+        for (int i = 0; i < 5; i++) {
+            assertThat(testee().associatedInformation(token).block().aud())
+                .isEmpty();
+        }
+
+        verify(tokenInfoResolver, times(1)).apply(token);
+        assertThat(getUsernameFromCache(token)).contains(Username.of(EMAIL));
+    }
+
+    public void mockTokenInfoResolverSuccess(Token token, TokenInfo expected) {
+        when(tokenInfoResolver.apply(token))
+            .thenReturn(Mono.just(expected));
+    }
+
+    public void mockTokenInfoResolverFailure(Token token, Exception expected) {
+        when(tokenInfoResolver.apply(token))
+            .thenReturn(Mono.error(expected));
+    }
+}


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

Reply via email to