Skip to content

Commit a283700

Browse files
committed
Add CacheSaml2AuthenticationRequestRepository
Closes gh-14793
1 parent 8cbe02e commit a283700

File tree

3 files changed

+215
-0
lines changed

3 files changed

+215
-0
lines changed

docs/modules/ROOT/pages/servlet/saml2/login/authentication-requests.adoc

+40
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,46 @@ open fun authenticationRequestRepository(): Saml2AuthenticationRequestRepository
8383
----
8484
======
8585

86+
=== Caching the `<saml2:AuthnRequest>` by the Relay State
87+
88+
If you don't want to use the session to store the `<saml2:AuthnRequest>`, you can also store it in a distributed cache.
89+
This can be helpful if you are trying to use `SameSite=Strict` and are losing the authentication request in the redirect from the Identity Provider.
90+
91+
[NOTE]
92+
=====
93+
It's important to remember that there are security benefits to storing it in the session.
94+
One such benefit is the natural login fixation defense it provides.
95+
For example, if an application looks the authentication request up from the session, then even if an attacker provides their own SAML response to a victim, the login will fail.
96+
97+
On the other hand, if we trust the InResponseTo or RelayState to retrieve the authentication request, then there's no way to know if the SAML response was requested by that handshake.
98+
=====
99+
100+
To help with this, Spring Security has `CacheSaml2AuthenticationRequestRepository`, which you can publish as a bean for the filter chain to pick up:
101+
102+
[tabs]
103+
======
104+
Java::
105+
+
106+
[source,java,role="primary"]
107+
----
108+
@Bean
109+
Saml2AuthenticationRequestRepository<?> authenticationRequestRepository() {
110+
return new CacheSaml2AuthenticationRequestRepository();
111+
}
112+
----
113+
114+
Kotlin::
115+
+
116+
[source,kotlin,role="secondary"]
117+
----
118+
@Bean
119+
fun authenticationRequestRepository(): Saml2AuthenticationRequestRepository<*> {
120+
return CacheSaml2AuthenticationRequestRepository()
121+
}
122+
----
123+
======
124+
125+
86126
[[servlet-saml2login-sp-initiated-factory-signing]]
87127
== Changing How the `<saml2:AuthnRequest>` Gets Sent
88128

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.saml2.provider.service.web;
18+
19+
import jakarta.servlet.http.HttpServletRequest;
20+
import jakarta.servlet.http.HttpServletResponse;
21+
22+
import org.springframework.cache.Cache;
23+
import org.springframework.cache.concurrent.ConcurrentMapCache;
24+
import org.springframework.security.saml2.core.Saml2ParameterNames;
25+
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
26+
import org.springframework.util.Assert;
27+
28+
/**
29+
* A cache-based {@link Saml2AuthenticationRequestRepository}. This can be handy when you
30+
* are dropping requests due to using SameSite=Strict and the previous session is lost.
31+
*
32+
* <p>
33+
* On the other hand, this presents a tradeoff where the application can only tell that
34+
* the given authentication request was created by this application, but cannot guarantee
35+
* that it was for the user trying to log in. Please see the reference for details.
36+
*
37+
* @author Josh Cummings
38+
* @since 6.5
39+
*/
40+
public final class CacheSaml2AuthenticationRequestRepository
41+
implements Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> {
42+
43+
private Cache cache = new ConcurrentMapCache("authentication-requests");
44+
45+
@Override
46+
public AbstractSaml2AuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
47+
String relayState = request.getParameter(Saml2ParameterNames.RELAY_STATE);
48+
Assert.notNull(relayState, "relayState must not be null");
49+
return this.cache.get(relayState, AbstractSaml2AuthenticationRequest.class);
50+
}
51+
52+
@Override
53+
public void saveAuthenticationRequest(AbstractSaml2AuthenticationRequest authenticationRequest,
54+
HttpServletRequest request, HttpServletResponse response) {
55+
String relayState = request.getParameter(Saml2ParameterNames.RELAY_STATE);
56+
Assert.notNull(relayState, "relayState must not be null");
57+
this.cache.put(relayState, authenticationRequest);
58+
}
59+
60+
@Override
61+
public AbstractSaml2AuthenticationRequest removeAuthenticationRequest(HttpServletRequest request,
62+
HttpServletResponse response) {
63+
String relayState = request.getParameter(Saml2ParameterNames.RELAY_STATE);
64+
Assert.notNull(relayState, "relayState must not be null");
65+
AbstractSaml2AuthenticationRequest authenticationRequest = this.cache.get(relayState,
66+
AbstractSaml2AuthenticationRequest.class);
67+
if (authenticationRequest == null) {
68+
return null;
69+
}
70+
this.cache.evict(relayState);
71+
return authenticationRequest;
72+
}
73+
74+
/**
75+
* Use this {@link Cache} instance. The default is an in-memory cache, which means it
76+
* won't work in a clustered environment. Instead, replace it here with a distributed
77+
* cache.
78+
* @param cache the {@link Cache} instance to use
79+
*/
80+
public void setCache(Cache cache) {
81+
this.cache = cache;
82+
}
83+
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.saml2.provider.service.web;
18+
19+
import org.junit.jupiter.api.Test;
20+
21+
import org.springframework.cache.Cache;
22+
import org.springframework.cache.concurrent.ConcurrentMapCache;
23+
import org.springframework.mock.web.MockHttpServletRequest;
24+
import org.springframework.security.saml2.core.Saml2ParameterNames;
25+
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
26+
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
27+
import org.springframework.security.saml2.provider.service.authentication.TestSaml2PostAuthenticationRequests;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
31+
import static org.mockito.ArgumentMatchers.any;
32+
import static org.mockito.ArgumentMatchers.eq;
33+
import static org.mockito.Mockito.spy;
34+
import static org.mockito.Mockito.verify;
35+
36+
/**
37+
* Tests for {@link CacheSaml2AuthenticationRequestRepository}
38+
*/
39+
class CacheSaml2AuthenticationRequestRepositoryTests {
40+
41+
CacheSaml2AuthenticationRequestRepository repository = new CacheSaml2AuthenticationRequestRepository();
42+
43+
@Test
44+
void loadAuthenticationRequestWhenCachedThenReturns() {
45+
MockHttpServletRequest request = new MockHttpServletRequest();
46+
request.setParameter(Saml2ParameterNames.RELAY_STATE, "test");
47+
Saml2PostAuthenticationRequest authenticationRequest = TestSaml2PostAuthenticationRequests.create();
48+
this.repository.saveAuthenticationRequest(authenticationRequest, request, null);
49+
assertThat(this.repository.loadAuthenticationRequest(request)).isEqualTo(authenticationRequest);
50+
this.repository.removeAuthenticationRequest(request, null);
51+
assertThat(this.repository.loadAuthenticationRequest(request)).isNull();
52+
}
53+
54+
@Test
55+
void loadAuthenticationRequestWhenNoRelayStateThenException() {
56+
MockHttpServletRequest request = new MockHttpServletRequest();
57+
assertThatExceptionOfType(IllegalArgumentException.class)
58+
.isThrownBy(() -> this.repository.loadAuthenticationRequest(request));
59+
}
60+
61+
@Test
62+
void saveAuthenticationRequestWhenNoRelayStateThenException() {
63+
MockHttpServletRequest request = new MockHttpServletRequest();
64+
assertThatExceptionOfType(IllegalArgumentException.class)
65+
.isThrownBy(() -> this.repository.saveAuthenticationRequest(null, request, null));
66+
}
67+
68+
@Test
69+
void removeAuthenticationRequestWhenNoRelayStateThenException() {
70+
MockHttpServletRequest request = new MockHttpServletRequest();
71+
assertThatExceptionOfType(IllegalArgumentException.class)
72+
.isThrownBy(() -> this.repository.removeAuthenticationRequest(request, null));
73+
}
74+
75+
@Test
76+
void repositoryWhenCustomCacheThenUses() {
77+
CacheSaml2AuthenticationRequestRepository repository = new CacheSaml2AuthenticationRequestRepository();
78+
Cache cache = spy(new ConcurrentMapCache("requests"));
79+
repository.setCache(cache);
80+
MockHttpServletRequest request = new MockHttpServletRequest();
81+
request.setParameter(Saml2ParameterNames.RELAY_STATE, "test");
82+
Saml2PostAuthenticationRequest authenticationRequest = TestSaml2PostAuthenticationRequests.create();
83+
repository.saveAuthenticationRequest(authenticationRequest, request, null);
84+
verify(cache).put(eq("test"), any());
85+
repository.loadAuthenticationRequest(request);
86+
verify(cache).get("test", AbstractSaml2AuthenticationRequest.class);
87+
repository.removeAuthenticationRequest(request, null);
88+
verify(cache).evict("test");
89+
}
90+
91+
}

0 commit comments

Comments
 (0)