CacheSaml2AuthenticationRequestRepository.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed 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.entrystore.rest.springboot.security;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Ticker;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.rest.springboot.configuration.CaffeineCacheSource;
import org.entrystore.rest.springboot.configuration.SamlCustomConfiguration;
import org.entrystore.rest.springboot.util.OldestFirstBoundedCache;
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* Stores SAML authentication requests in a Caffeine cache keyed by the relay state token,
* instead of in the HTTP session. This avoids the SameSite=Strict cookie problem where
* the browser withholds the session cookie on the cross-site POST from the IdP back to
* the ACS endpoint.
*
* <p>Entries expire after {@link SamlCustomConfiguration#requestLifetime()}: Spring validates the response's
* {@code InResponseTo} against the request loaded here, so a login that outlives its entry fails.
*
* <p>The cache is written on the anonymous login-start path, which {@link SsoLoginRateLimitFilter} limits per client.
* Against a flood from many clients, {@link OldestFirstBoundedCache} bounds the heap and evicts the oldest login first.
*/
@Slf4j
@Component
public class CacheSaml2AuthenticationRequestRepository
implements Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest>, CaffeineCacheSource {
// At about 2 KB per request, 50k entries ≈ 100 MB at worst; a flood needs ~55 req/s for 15 minutes to fill it.
static final long MAX_ENTRIES = 50_000;
private final OldestFirstBoundedCache<String, AbstractSaml2AuthenticationRequest> cache;
public CacheSaml2AuthenticationRequestRepository(SamlCustomConfiguration samlConfiguration, Ticker ticker) {
this.cache = new OldestFirstBoundedCache<>(samlConfiguration.requestLifetime(), MAX_ENTRIES, ticker, log,
"SAML authentication-request");
}
@Override
public Map<String, Cache<?, ?>> caffeineCaches() {
return Map.of("saml2-authn-requests", cache.caffeineCache());
}
@Override
public AbstractSaml2AuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
String relayState = request.getParameter("RelayState");
return (relayState != null) ? cache.getIfPresent(relayState) : null;
}
@Override
public void saveAuthenticationRequest(AbstractSaml2AuthenticationRequest authRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (authRequest == null) {
// As in Spring's session repository a null request means removal, here of the one named by RelayState.
removeAuthenticationRequest(request, response);
return;
}
if (authRequest.getRelayState() != null) {
cache.put(authRequest.getRelayState(), authRequest);
}
}
/**
* Removes and returns the request for the {@code RelayState} of this ACS POST. The atomic
* {@link OldestFirstBoundedCache#remove} guarantees that of two concurrent POSTs carrying the same RelayState
* only one takes the request out of the cache; a {@code getIfPresent} + {@code invalidate} pair would
* hand it to both. This is not a replay guard on its own: {@code Saml2WebSsoAuthenticationFilter}
* obtains the request through the converter's non-destructive {@link #loadAuthenticationRequest}
* before calling this method and discards the value returned here. The atomic form matches
* {@link CacheOAuth2AuthorizationRequestRepository}, whose filter does consume the removed value.
*/
@Override
public AbstractSaml2AuthenticationRequest removeAuthenticationRequest(
HttpServletRequest request, HttpServletResponse response) {
String relayState = request.getParameter("RelayState");
return (relayState != null) ? cache.remove(relayState) : null;
}
}