diff --git a/pom.xml b/pom.xml
index 17fc39f56..9c1e4dea9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -95,6 +95,14 @@
org.springframework.boot
spring-boot-starter-oauth2-client
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+
diff --git a/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java b/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java
index 19dce67f3..80378c911 100644
--- a/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java
+++ b/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java
@@ -1,6 +1,5 @@
package com.czertainly.core.auth.oauth2;
-import com.czertainly.api.model.core.logging.enums.AuthMethod;
import com.czertainly.api.model.core.logging.enums.Operation;
import com.czertainly.api.model.core.logging.enums.OperationResult;
import com.czertainly.api.model.core.settings.SettingsSection;
@@ -62,7 +61,7 @@ public AbstractAuthenticationToken convert(@Nullable Jwt source) {
throw e;
}
- AuthenticationInfo authInfo = authenticationClient.authenticate(AuthMethod.TOKEN, claims, false);
+ AuthenticationInfo authInfo = authenticationClient.authenticateByToken(claims);
CzertainlyUserDetails userDetails = new CzertainlyUserDetails(authInfo);
// Provider settings will not be null, otherwise converter would not have been reached from decoder
logger.debug("User '{}' has been authenticated using JWT from OAuth2 Provider '{}'.", userDetails.getUsername(), providerSettings == null ? " " : providerSettings.getName());
diff --git a/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java b/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java
index 040d1e819..f0fa77460 100644
--- a/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java
+++ b/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java
@@ -139,7 +139,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
private void authenticate(HttpServletRequest request, Map claims, ClientRegistration clientRegistration) {
AuthenticationInfo authInfo;
try {
- authInfo = authenticationClient.authenticate(AuthMethod.TOKEN, claims, false);
+ authInfo = authenticationClient.authenticateByToken(claims);
CzertainlyAuthenticationToken authenticationToken = new CzertainlyAuthenticationToken(new CzertainlyUserDetails(authInfo));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
LOGGER.debug("Session of user '{}' logged using OAuth2 Provider '{}' has been successfully validated.", authenticationToken.getPrincipal().getUsername(), clientRegistration.getRegistrationId());
diff --git a/src/main/java/com/czertainly/core/config/AuthCacheProperties.java b/src/main/java/com/czertainly/core/config/AuthCacheProperties.java
new file mode 100644
index 000000000..897a8a19c
--- /dev/null
+++ b/src/main/java/com/czertainly/core/config/AuthCacheProperties.java
@@ -0,0 +1,15 @@
+package com.czertainly.core.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "caching.authentication")
+public record AuthCacheProperties(
+ int ttlMinutes,
+ int maxSize
+) {
+
+ public AuthCacheProperties {
+ if (ttlMinutes <= 0) ttlMinutes = 5;
+ if (maxSize <= 0) maxSize = 500;
+ }
+}
diff --git a/src/main/java/com/czertainly/core/config/CacheConfig.java b/src/main/java/com/czertainly/core/config/CacheConfig.java
new file mode 100644
index 000000000..51d9b1826
--- /dev/null
+++ b/src/main/java/com/czertainly/core/config/CacheConfig.java
@@ -0,0 +1,52 @@
+package com.czertainly.core.config;
+
+import com.czertainly.core.security.authn.client.TokenJtiIndex;
+import com.czertainly.core.security.authn.client.UserCertificateIndex;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.caffeine.CaffeineCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+
+import java.util.concurrent.TimeUnit;
+
+@Configuration
+@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE)
+@EnableConfigurationProperties(AuthCacheProperties.class)
+public class CacheConfig {
+
+ public static final String SIGNING_PROFILES_CACHE = "signingProfiles";
+ public static final String TSP_PROFILES_CACHE = "tspProfiles";
+ public static final String CERTIFICATE_CHAIN_CACHE = "certificateChain";
+ public static final String SYSTEM_USER_AUTH_CACHE = "systemUserAuth";
+ public static final String USER_UUID_AUTH_CACHE = "userUuidAuth";
+ public static final String CERTIFICATE_AUTH_CACHE = "certificateAuth";
+ public static final String TOKEN_AUTH_CACHE = "tokenAuth";
+ public static final String FORMATTER_CONNECTOR_CACHE = "formatterConnector";
+ public static final String CRYPTOGRAPHIC_KEY_ITEM_CACHE = "cryptographicKeyItem";
+
+ @Bean
+ public CacheManager cacheManager(AuthCacheProperties cacheProperties, TokenJtiIndex tokenJtiIndex, UserCertificateIndex userCertificateIndex) {
+ CaffeineCacheManager mgr = new CaffeineCacheManager(SIGNING_PROFILES_CACHE, TSP_PROFILES_CACHE, CERTIFICATE_CHAIN_CACHE, SYSTEM_USER_AUTH_CACHE, USER_UUID_AUTH_CACHE, FORMATTER_CONNECTOR_CACHE, CRYPTOGRAPHIC_KEY_ITEM_CACHE);
+ mgr.setCaffeine(Caffeine.newBuilder()
+ .expireAfterWrite(cacheProperties.ttlMinutes(), TimeUnit.MINUTES)
+ .maximumSize(cacheProperties.maxSize())
+ .recordStats());
+ mgr.registerCustomCache(CERTIFICATE_AUTH_CACHE, Caffeine.newBuilder()
+ .expireAfterWrite(cacheProperties.ttlMinutes(), TimeUnit.MINUTES)
+ .maximumSize(cacheProperties.maxSize())
+ .recordStats()
+ .removalListener(userCertificateIndex)
+ .build());
+ mgr.registerCustomCache(TOKEN_AUTH_CACHE, Caffeine.newBuilder()
+ .expireAfterWrite(cacheProperties.ttlMinutes(), TimeUnit.MINUTES)
+ .maximumSize(cacheProperties.maxSize())
+ .recordStats()
+ .removalListener(tokenJtiIndex)
+ .build());
+ return mgr;
+ }
+}
diff --git a/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java b/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java
index fb561e0cf..3a07f7926 100644
--- a/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java
+++ b/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java
@@ -4,6 +4,7 @@
import com.czertainly.core.security.authn.client.AuthenticationInfo;
import com.czertainly.core.security.authn.client.CzertainlyAuthenticationClient;
import com.czertainly.core.util.AuthHelper;
+import com.czertainly.core.util.CertificateUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
@@ -19,7 +20,11 @@
import java.io.IOException;
import java.net.InetAddress;
+import java.net.URLDecoder;
import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
import java.util.UUID;
public class CzertainlyAuthenticationFilter extends OncePerRequestFilter {
@@ -44,17 +49,14 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
log.trace("Going to authenticate the '{}' request on '{}'.", request.getMethod(), request.getRequestURI());
try {
-
- AuthMethod authMethod = AuthMethod.NONE;
- Object authData = null;
-
- if (request.getHeader(certificateHeaderName) != null) {
- authMethod = AuthMethod.CERTIFICATE;
- authData = request.getHeader(certificateHeaderName);
+ AuthenticationInfo authInfo;
+ String rawCertHeader = request.getHeader(certificateHeaderName);
+ if (rawCertHeader != null) {
+ authInfo = authenticateByCertificate(rawCertHeader);
+ } else {
+ authInfo = authClient.authenticate(AuthMethod.NONE, null, isLocalhostAddress(request));
}
- AuthenticationInfo authInfo = authClient.authenticate(authMethod, authData, isLocalhostAddress(request));
-
Authentication authentication;
if (authInfo.isAnonymous()) {
authentication = new CzertainlyAnonymousToken(UUID.randomUUID().toString(), new CzertainlyUserDetails(authInfo), authInfo.getAuthorities());
@@ -62,15 +64,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
authentication = new CzertainlyAuthenticationToken(new CzertainlyUserDetails(authInfo));
}
- SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
- securityContext.setAuthentication(authentication);
- SecurityContextHolder.setContext(securityContext);
- CzertainlyUserDetails userDetails = (CzertainlyUserDetails) authentication.getPrincipal();
- if (userDetails.getAuthMethod() == AuthMethod.CERTIFICATE) {
- log.debug("User with username '{}' has been successfully authenticated with certificate.", userDetails.getUsername());
- } else {
- log.debug("User has not been identified, using anonymous user.");
- }
+ updateSecurityContext(authentication);
} catch (AuthenticationException e) {
SecurityContextHolder.clearContext();
@@ -84,6 +78,33 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht
filterChain.doFilter(request, response);
}
+ private static void updateSecurityContext(Authentication authentication) {
+ SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+ securityContext.setAuthentication(authentication);
+ SecurityContextHolder.setContext(securityContext);
+ CzertainlyUserDetails userDetails = (CzertainlyUserDetails) authentication.getPrincipal();
+ if (userDetails.getAuthMethod() == AuthMethod.CERTIFICATE) {
+ log.debug("User with username '{}' has been successfully authenticated with certificate.", userDetails.getUsername());
+ } else {
+ log.debug("User has not been identified, using anonymous user.");
+ }
+ }
+
+ private AuthenticationInfo authenticateByCertificate(String rawCertHeader) {
+ AuthenticationInfo authInfo;
+ try {
+ String decoded = URLDecoder.decode(rawCertHeader, StandardCharsets.UTF_8);
+ byte[] derBytes = Base64.getDecoder().decode(CertificateUtil.normalizeCertificateContent(decoded));
+ String thumbprint = CertificateUtil.getThumbprint(derBytes);
+ authInfo = authClient.authenticateByCertificate(rawCertHeader, thumbprint);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 algorithm not available", e);
+ } catch (IllegalArgumentException e) {
+ throw new CzertainlyAuthenticationException("Invalid certificate header: " + e.getMessage(), e);
+ }
+ return authInfo;
+ }
+
private boolean isAuthenticationNeeded(final HttpServletRequest request) {
SecurityContext securityContext = SecurityContextHolder.getContext();
diff --git a/src/main/java/com/czertainly/core/security/authn/client/AuthenticationCache.java b/src/main/java/com/czertainly/core/security/authn/client/AuthenticationCache.java
new file mode 100644
index 000000000..90620eb13
--- /dev/null
+++ b/src/main/java/com/czertainly/core/security/authn/client/AuthenticationCache.java
@@ -0,0 +1,78 @@
+package com.czertainly.core.security.authn.client;
+
+import java.util.UUID;
+import java.util.function.Supplier;
+
+public interface AuthenticationCache {
+
+ /**
+ * Returns cached authentication for a system user, or invokes {@code loader} and caches the result.
+ * Cached by username. System usernames (superadmin, acme, scep, …) are stable identifiers
+ * that never change, so the cache entry remains valid for its full TTL.
+ *
+ * @param username system username used as the cache key
+ * @param loader called on a cache miss to produce the {@link AuthenticationInfo}
+ * @return the cached or freshly loaded {@link AuthenticationInfo}
+ */
+ AuthenticationInfo getOrAuthenticateSystemUser(String username, Supplier loader);
+
+ /**
+ * Returns cached authentication for a user identified by UUID, or invokes {@code loader} and caches the result.
+ * Effective for repeated internal impersonation calls within the TTL window.
+ *
+ * @param userUuid UUID of the user, used as the cache key
+ * @param loader called on a cache miss to produce the {@link AuthenticationInfo}
+ * @return the cached or freshly loaded {@link AuthenticationInfo}
+ */
+ AuthenticationInfo getOrAuthenticateByUserUuid(UUID userUuid, Supplier loader);
+
+ /**
+ * Returns cached authentication for a client-certificate request, or invokes {@code loader} and caches the result.
+ * Cached by SHA-256 of the DER-encoded certificate bytes (computed in CzertainlyAuthenticationFilter),
+ * matching the DB {@code certificate.fingerprint} field. All requests carrying the same client certificate
+ * share one cache entry. The entry is evicted immediately on revocation via CertificateServiceImpl.
+ *
+ * @param thumbprint SHA-256 fingerprint of the client certificate, used as the cache key
+ * @param loader called on a cache miss to produce the {@link AuthenticationInfo}
+ * @return the cached or freshly loaded {@link AuthenticationInfo}
+ */
+ AuthenticationInfo getOrAuthenticateByCertificate(String thumbprint, Supplier loader);
+
+ /**
+ * Returns cached authentication for a bearer-token request, or invokes {@code loader} and caches the result.
+ * Cached by the {@code jti} claim, which is unique per token issuance. All requests that carry the same
+ * access token share one cache entry for its lifetime. When the token is refreshed, a new {@code jti}
+ * causes a cache miss, triggering a fresh auth-service call. Tokens without a {@code jti} are never cached.
+ *
+ * @param jti the {@code jti} claim of the access token, used as the cache key; {@code null} skips caching
+ * @param loader called on a cache miss to produce the {@link AuthenticationInfo}
+ * @return the cached or freshly loaded {@link AuthenticationInfo}
+ */
+ AuthenticationInfo getOrAuthenticateByToken(String jti, Supplier loader);
+
+ /**
+ * Evicts all auth cache entries for a single user: their UUID entry, all token entries tracked
+ * in the jti index, and their certificate entry tracked in the certificate index.
+ * Use this for user-scoped changes (role assignment, disable, delete, certificate revocation)
+ * where only one user is affected and the system-user cache can be left untouched.
+ *
+ * @param userUuid UUID of the user whose cache entries should be evicted
+ */
+ void evictByUserUuid(UUID userUuid);
+
+ /**
+ * Evicts only the certificate-based auth cache entry for the given fingerprint.
+ * Use this when a certificate is disassociated from a user but the user's identity and
+ * roles are unchanged — their UUID and token cache entries remain valid.
+ *
+ * @param certFingerprint SHA-256 fingerprint of the certificate to evict
+ */
+ void evictByCertificateFingerprint(String certFingerprint);
+
+ /**
+ * Clears all four auth caches and the jti index (the per-user map of {@code jti} claims used to
+ * find and evict token cache entries when a user is modified). Use this for role-level mutations
+ * (permission changes, role deletion) that may affect any user, including system accounts.
+ */
+ void evictAll();
+}
diff --git a/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCache.java b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCache.java
new file mode 100644
index 000000000..451986a7d
--- /dev/null
+++ b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCache.java
@@ -0,0 +1,117 @@
+package com.czertainly.core.security.authn.client;
+
+import com.czertainly.core.config.CacheConfig;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+@Component
+public class CzertainlyAuthenticationCache implements AuthenticationCache {
+
+ private final CacheManager cacheManager;
+ private final TokenJtiIndex tokenJtiIndex;
+ private final UserCertificateIndex userCertificateIndex;
+ private final Cache certCache;
+ private final Cache tokenCache;
+
+ @Autowired
+ public CzertainlyAuthenticationCache(CacheManager cacheManager, TokenJtiIndex tokenJtiIndex, UserCertificateIndex userCertificateIndex) {
+ this.cacheManager = cacheManager;
+ this.tokenJtiIndex = tokenJtiIndex;
+ this.userCertificateIndex = userCertificateIndex;
+ this.certCache = Objects.requireNonNull(cacheManager.getCache(CacheConfig.CERTIFICATE_AUTH_CACHE));
+ this.tokenCache = Objects.requireNonNull(cacheManager.getCache(CacheConfig.TOKEN_AUTH_CACHE));
+ }
+
+ @Override
+ @Cacheable(value = CacheConfig.SYSTEM_USER_AUTH_CACHE, key = "#username", unless = "#result.anonymous")
+ public AuthenticationInfo getOrAuthenticateSystemUser(String username, Supplier loader) {
+ return loader.get();
+ }
+
+ @Override
+ @Cacheable(value = CacheConfig.USER_UUID_AUTH_CACHE, key = "#userUuid", unless = "#result.anonymous")
+ public AuthenticationInfo getOrAuthenticateByUserUuid(UUID userUuid, Supplier loader) {
+ return loader.get();
+ }
+
+ // Manual caching (instead of @Cacheable) keeps userCertificateIndex in sync, enabling targeted
+ // per-user certificate eviction via evictByUserUuid().
+ @Override
+ public AuthenticationInfo getOrAuthenticateByCertificate(String thumbprint, Supplier loader) {
+ Cache.ValueWrapper cached = certCache.get(thumbprint);
+ if (cached != null) {
+ return (AuthenticationInfo) cached.get();
+ }
+ AuthenticationInfo result = loader.get();
+ if (!result.isAnonymous()) {
+ certCache.put(thumbprint, result);
+ userCertificateIndex.add(UUID.fromString(result.getUserUuid()), thumbprint);
+ }
+ return result;
+ }
+
+ // Manual caching (instead of @Cacheable) keeps tokenJtiIndex in sync, enabling targeted
+ // per-user eviction via evictTokensByUserUuid().
+ @Override
+ public AuthenticationInfo getOrAuthenticateByToken(String jti, Supplier loader) {
+ if (jti == null) {
+ return loader.get();
+ }
+ Cache.ValueWrapper cached = tokenCache.get(jti);
+ if (cached != null) {
+ return (AuthenticationInfo) cached.get();
+ }
+ AuthenticationInfo result = loader.get();
+ if (!result.isAnonymous()) {
+ tokenCache.put(jti, result);
+ tokenJtiIndex.add(UUID.fromString(result.getUserUuid()), jti);
+ }
+ return result;
+ }
+
+ @Override
+ public void evictByUserUuid(UUID userUuid) {
+ Objects.requireNonNull(cacheManager.getCache(CacheConfig.USER_UUID_AUTH_CACHE)).evict(userUuid);
+ evictTokensByUserUuid(userUuid);
+ evictCertificateByUserUuid(userUuid);
+ }
+
+ @Override
+ public void evictAll() {
+ tokenJtiIndex.clear();
+ userCertificateIndex.clear();
+ Objects.requireNonNull(cacheManager.getCache(CacheConfig.SYSTEM_USER_AUTH_CACHE)).clear();
+ Objects.requireNonNull(cacheManager.getCache(CacheConfig.USER_UUID_AUTH_CACHE)).clear();
+ certCache.clear();
+ tokenCache.clear();
+ }
+
+ // Looks up jtis for the user in the index and evicts each one from the token cache.
+ // No-op if the user has no cached tokens.
+ private void evictTokensByUserUuid(UUID userUuid) {
+ Set jtis = tokenJtiIndex.removeUser(userUuid);
+ if (jtis == null) return;
+ jtis.forEach(tokenCache::evict);
+ }
+
+ // Looks up the fingerprint for the user in the index and evicts it from the certificate cache.
+ // No-op if the user has no cached certificate.
+ private void evictCertificateByUserUuid(UUID userUuid) {
+ String fingerprint = userCertificateIndex.removeUser(userUuid);
+ if (fingerprint == null) return;
+ certCache.evict(fingerprint);
+ }
+
+ @Override
+ public void evictByCertificateFingerprint(String certFingerprint) {
+ certCache.evict(certFingerprint);
+ }
+}
diff --git a/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java
index 32bf2217c..f0742a63c 100644
--- a/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java
+++ b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java
@@ -41,18 +41,45 @@ public class CzertainlyAuthenticationClient extends CzertainlyBaseAuthentication
private final ObjectMapper objectMapper;
private final String customAuthServiceBaseUrl;
+ private final AuthenticationCache authenticationCache;
@Value("${server.ssl.certificate-header-name}")
private String certificateHeaderName;
private final AuditLogService auditLogService;
- public CzertainlyAuthenticationClient(@Autowired AuditLogService auditLogService, @Autowired ObjectMapper objectMapper, @Value("${auth-service.base-url}") String customAuthServiceBaseUrl) {
+ public CzertainlyAuthenticationClient(
+ @Autowired AuditLogService auditLogService,
+ @Autowired ObjectMapper objectMapper,
+ @Autowired AuthenticationCache authenticationCache,
+ @Value("${auth-service.base-url}") String customAuthServiceBaseUrl) {
this.objectMapper = objectMapper;
this.auditLogService = auditLogService;
+ this.authenticationCache = authenticationCache;
this.customAuthServiceBaseUrl = customAuthServiceBaseUrl;
}
+ public AuthenticationInfo authenticateSystemUser(String username) {
+ return authenticationCache.getOrAuthenticateSystemUser(
+ username, () -> authenticate(AuthMethod.USER_PROXY, username, false));
+ }
+
+ public AuthenticationInfo authenticateByUserUuid(UUID userUuid) {
+ return authenticationCache.getOrAuthenticateByUserUuid(
+ userUuid, () -> authenticate(AuthMethod.USER_PROXY, userUuid, false));
+ }
+
+ public AuthenticationInfo authenticateByCertificate(String rawCertHeader, String certificateThumbprint) {
+ return authenticationCache.getOrAuthenticateByCertificate(
+ certificateThumbprint, () -> authenticate(AuthMethod.CERTIFICATE, rawCertHeader, false));
+ }
+
+ public AuthenticationInfo authenticateByToken(Map claims) {
+ String jti = (String) claims.get("jti");
+ return authenticationCache.getOrAuthenticateByToken(
+ jti, () -> authenticate(AuthMethod.TOKEN, claims, false));
+ }
+
public AuthenticationInfo authenticate(AuthMethod authMethod, Object authData, boolean isLocalhostRequest) throws AuthenticationException {
AuthenticationRequestDto authRequest = getAuthPayload(authMethod, authData, isLocalhostRequest);
@@ -80,7 +107,7 @@ public AuthenticationInfo authenticate(AuthMethod authMethod, Object authData, b
throw new CzertainlyAuthenticationException(message);
}
return createAuthenticationInfo(authRequest.getAuthMethod(), response);
- } catch (WebClientResponseException.InternalServerError | WebClientRequestException e) {
+ } catch (WebClientResponseException.InternalServerError | WebClientRequestException | IllegalStateException e) {
String message = "An error occurred when calling authentication service: " + e.getMessage();
AuthHelper.logAndAuditAuthFailure(logger, auditLogService, message, authRequest.getAuthData(false));
throw new CzertainlyAuthenticationException(message, e);
diff --git a/src/main/java/com/czertainly/core/security/authn/client/TokenJtiIndex.java b/src/main/java/com/czertainly/core/security/authn/client/TokenJtiIndex.java
new file mode 100644
index 000000000..54b36b788
--- /dev/null
+++ b/src/main/java/com/czertainly/core/security/authn/client/TokenJtiIndex.java
@@ -0,0 +1,46 @@
+package com.czertainly.core.security.authn.client;
+
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.github.benmanes.caffeine.cache.RemovalListener;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Secondary index: userUuid → set of JTI claims cached for that user.
+ * Enables per-user token eviction when only the userUuid is known.
+ * Kept in sync automatically via the Caffeine removal listener registered in CacheConfig.
+ */
+@Component
+public class TokenJtiIndex implements RemovalListener