Skip to content

Commit 9b72437

Browse files
committed
Rework Saml2 Authentication Statement
This commit separates the authentication principal, the assertion details, and the relying party tenant into separate components. This allows the principal to be completely decoupled from how Spring Security triggers and processes SLO. Specifically, it adds Saml2AssertionAuthentication, a new authentication implementation that allows an Object principal and a Saml2ResponseAssertionAccessor credential. It also moves the relying party registration id from Saml2AuthenticatedPrincipal to Saml2AssertionAuthentication. As such, Saml2AuthenticatedPrincipal is now deprecated in favor of placing its assertion components in Saml2ResponseAssertionAccessor and the relying party registration id in Saml2AssertionAuthentication. Closes gh-10820
1 parent 02a8c41 commit 9b72437

File tree

25 files changed

+558
-136
lines changed

25 files changed

+558
-136
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
3434
import org.springframework.security.core.Authentication;
3535
import org.springframework.security.core.context.SecurityContextHolderStrategy;
36-
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationInfo;
36+
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
37+
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
38+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor;
3739
import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml4LogoutRequestValidator;
3840
import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml4LogoutResponseValidator;
3941
import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml5LogoutRequestValidator;
@@ -531,7 +533,16 @@ private static class Saml2RequestMatcher implements RequestMatcher {
531533
@Override
532534
public boolean matches(HttpServletRequest request) {
533535
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
534-
return Saml2AuthenticationInfo.fromAuthentication(authentication) != null;
536+
if (authentication == null) {
537+
return false;
538+
}
539+
if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal) {
540+
return true;
541+
}
542+
if (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor) {
543+
return true;
544+
}
545+
return authentication instanceof Saml2Authentication;
535546
}
536547

537548
}

config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
import org.springframework.security.core.Authentication;
3232
import org.springframework.security.core.context.SecurityContextHolder;
3333
import org.springframework.security.core.context.SecurityContextHolderStrategy;
34-
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationInfo;
34+
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
35+
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
36+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor;
3537
import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver;
3638
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestFilter;
3739
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseFilter;
@@ -236,7 +238,16 @@ public static class Saml2RequestMatcher implements RequestMatcher {
236238
@Override
237239
public boolean matches(HttpServletRequest request) {
238240
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
239-
return Saml2AuthenticationInfo.fromAuthentication(authentication) != null;
241+
if (authentication == null) {
242+
return false;
243+
}
244+
if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal) {
245+
return true;
246+
}
247+
if (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor) {
248+
return true;
249+
}
250+
return authentication instanceof Saml2Authentication;
240251
}
241252

242253
public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {

config/src/test/java/org/springframework/security/SerializationSamples.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,14 @@
170170
import org.springframework.security.saml2.core.Saml2X509Credential;
171171
import org.springframework.security.saml2.credentials.TestSaml2X509Credentials;
172172
import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
173+
import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication;
173174
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
174175
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
175176
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken;
176177
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
177178
import org.springframework.security.saml2.provider.service.authentication.Saml2RedirectAuthenticationRequest;
179+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion;
180+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor;
178181
import org.springframework.security.saml2.provider.service.authentication.TestSaml2AuthenticationTokens;
179182
import org.springframework.security.saml2.provider.service.authentication.TestSaml2Authentications;
180183
import org.springframework.security.saml2.provider.service.authentication.TestSaml2LogoutRequests;
@@ -520,8 +523,16 @@ final class SerializationSamples {
520523
generatorByClassName.put(Saml2Exception.class, (r) -> new Saml2Exception("message", new IOException("fail")));
521524
generatorByClassName.put(DefaultSaml2AuthenticatedPrincipal.class,
522525
(r) -> TestSaml2Authentications.authentication().getPrincipal());
523-
generatorByClassName.put(Saml2Authentication.class,
524-
(r) -> applyDetails(TestSaml2Authentications.authentication()));
526+
Saml2Authentication saml2 = TestSaml2Authentications.authentication();
527+
generatorByClassName.put(Saml2Authentication.class, (r) -> applyDetails(saml2));
528+
Saml2ResponseAssertionAccessor assertion = Saml2ResponseAssertion.withResponseValue("response")
529+
.nameId("name")
530+
.sessionIndexes(List.of("id"))
531+
.attributes(Map.of("key", List.of("value")))
532+
.build();
533+
generatorByClassName.put(Saml2ResponseAssertion.class, (r) -> assertion);
534+
generatorByClassName.put(Saml2AssertionAuthentication.class, (r) -> applyDetails(
535+
new Saml2AssertionAuthentication(assertion, authentication.getAuthorities(), "id")));
525536
generatorByClassName.put(Saml2PostAuthenticationRequest.class,
526537
(r) -> TestSaml2PostAuthenticationRequests.create());
527538
generatorByClassName.put(Saml2RedirectAuthenticationRequest.class,

docs/modules/ROOT/pages/migration/servlet/saml2.adoc

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,57 @@ fun logoutResponseResolver(registrations: RelyingPartyRegistrationRepository?):
5454
----
5555
======
5656

57+
== Favor `Saml2ResponseAuthenticationAccessor` over `Saml2AuthenticatedPrincipal`
58+
59+
Spring Security 7 separates `<saml2:Assertion>` details from the principal.
60+
This allows Spring Security to retrieve needed assertion details to perform Single Logout.
61+
62+
This deprecates `Saml2AuthenticatedPrincipal`.
63+
You no longer need to implement it to use `Saml2Authentication`.
64+
65+
Instead, the credential implements `Saml2ResponseAssertionAccessor`, which Spring Security 7 favors when determining the appropriate action based on the authentication.
66+
67+
This change is made automatically for you when using the defaults.
68+
69+
If this causes you trouble when upgrading, you can publish a custom `ResponseAuhenticationConverter` to return a `Saml2Authentication` instead of returning a `Saml2AssertionAuthentication` like so:
70+
71+
[tabs]
72+
======
73+
Java::
74+
+
75+
[source,java,role="primary"]
76+
----
77+
@Bean
78+
OpenSaml5AuthenticationProvider authenticationProvider() {
79+
OpenSaml5AuthenticationProvider authenticationProvider =
80+
new OpenSaml5AuthenticationProvider();
81+
ResponseAuthenticationConverter defaults = new ResponseAuthenticationConverter();
82+
authenticationProvider.setResponseAuthenticationConverter(
83+
defaults.andThen((authentication) -> new Saml2Authentication(
84+
authentication.getPrincipal(),
85+
authentication.getSaml2Response(),
86+
authentication.getAuthorities())));
87+
return authenticationProvider;
88+
}
89+
----
90+
91+
Kotlin::
92+
+
93+
[source,kotlin,role="secondary"]
94+
----
95+
@Bean
96+
fun authenticationProvider(): OpenSaml5AuthenticationProvider {
97+
val authenticationProvider = OpenSaml5AuthenticationProvider()
98+
val defaults = ResponseAuthenticationConverter()
99+
authenticationProvider.setResponseAuthenticationConverter(
100+
defaults.andThen { authentication ->
101+
Saml2Authentication(authentication.getPrincipal(),
102+
authentication.getSaml2Response(),
103+
authentication.getAuthorities())
104+
})
105+
return authenticationProvider
106+
}
107+
----
108+
======
109+
110+
If you are constructing a `Saml2Authentication` instance yourself, consider changing to `Saml2AssertionAuthentication` to get the same benefit as the current default.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,10 @@ class MyUserDetailsResponseAuthenticationConverter implements Converter<Response
341341
Saml2Authentication authentication = this.delegate.convert(responseToken); <1>
342342
UserDetails principal = this.userDetailsService.loadByUsername(username); <2>
343343
String saml2Response = authentication.getSaml2Response();
344+
Saml2ResponseAssertionAccessor assertion = new OpenSamlResponseAssertionAccessor(
345+
saml2Response, CollectionUtils.getFirst(response.getAssertions()));
344346
Collection<GrantedAuthority> authorities = principal.getAuthorities();
345-
return new Saml2Authentication((AuthenticatedPrincipal) userDetails, saml2Response, authorities); <3>
347+
return new Saml2AssertionAuthentication(userDetails, assertion, authorities); <3>
346348
}
347349
348350
}
@@ -361,8 +363,10 @@ open class MyUserDetailsResponseAuthenticationConverter(val delegate: ResponseAu
361363
val authentication = this.delegate.convert(responseToken) <1>
362364
val principal = this.userDetailsService.loadByUsername(username) <2>
363365
val saml2Response = authentication.getSaml2Response()
366+
val assertion = OpenSamlResponseAssertionAccessor(
367+
saml2Response, CollectionUtils.getFirst(response.getAssertions()))
364368
val authorities = principal.getAuthorities()
365-
return Saml2Authentication(userDetails as AuthenticatedPrincipal, saml2Response, authorities) <3>
369+
return Saml2AssertionAuthentication(userDetails, assertion, authorities) <3>
366370
}
367371
368372
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.jackson2;
18+
19+
import java.util.Collection;
20+
21+
import com.fasterxml.jackson.annotation.JsonAutoDetect;
22+
import com.fasterxml.jackson.annotation.JsonCreator;
23+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
24+
import com.fasterxml.jackson.annotation.JsonProperty;
25+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
26+
27+
import org.springframework.security.core.GrantedAuthority;
28+
import org.springframework.security.jackson2.SecurityJackson2Modules;
29+
import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication;
30+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor;
31+
32+
/**
33+
* Jackson Mixin class helps in serialize/deserialize
34+
* {@link Saml2AssertionAuthentication}.
35+
*
36+
* <pre>
37+
* ObjectMapper mapper = new ObjectMapper();
38+
* mapper.registerModule(new Saml2Jackson2Module());
39+
* </pre>
40+
*
41+
* @author Josh Cummings
42+
* @since 7.0
43+
* @see Saml2Jackson2Module
44+
* @see SecurityJackson2Modules
45+
*/
46+
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
47+
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
48+
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
49+
@JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true)
50+
class Saml2AssertionAuthenticationMixin {
51+
52+
@JsonCreator
53+
Saml2AssertionAuthenticationMixin(@JsonProperty("principal") Object principal,
54+
@JsonProperty("assertion") Saml2ResponseAssertionAccessor assertion,
55+
@JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities,
56+
@JsonProperty("relyingPartyRegistrationId") String relyingPartyRegistrationId) {
57+
}
58+
59+
}

saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
import org.springframework.security.jackson2.SecurityJackson2Modules;
2323
import org.springframework.security.saml2.core.Saml2Error;
2424
import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal;
25+
import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication;
2526
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
2627
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException;
2728
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
2829
import org.springframework.security.saml2.provider.service.authentication.Saml2RedirectAuthenticationRequest;
30+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion;
2931
import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest;
3032

3133
/**
@@ -49,6 +51,8 @@ public Saml2Jackson2Module() {
4951
@Override
5052
public void setupModule(SetupContext context) {
5153
context.setMixInAnnotations(Saml2Authentication.class, Saml2AuthenticationMixin.class);
54+
context.setMixInAnnotations(Saml2AssertionAuthentication.class, Saml2AssertionAuthenticationMixin.class);
55+
context.setMixInAnnotations(Saml2ResponseAssertion.class, SimpleSaml2ResponseAssertionAccessorMixin.class);
5256
context.setMixInAnnotations(DefaultSaml2AuthenticatedPrincipal.class,
5357
DefaultSaml2AuthenticatedPrincipalMixin.class);
5458
context.setMixInAnnotations(Saml2LogoutRequest.class, Saml2LogoutRequestMixin.class);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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.jackson2;
18+
19+
import java.util.List;
20+
import java.util.Map;
21+
22+
import com.fasterxml.jackson.annotation.JsonAutoDetect;
23+
import com.fasterxml.jackson.annotation.JsonCreator;
24+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
25+
import com.fasterxml.jackson.annotation.JsonProperty;
26+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
27+
28+
import org.springframework.security.jackson2.SecurityJackson2Modules;
29+
import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion;
30+
31+
/**
32+
* Jackson Mixin class helps in serialize/deserialize {@link Saml2ResponseAssertion}.
33+
*
34+
* <pre>
35+
* ObjectMapper mapper = new ObjectMapper();
36+
* mapper.registerModule(new Saml2Jackson2Module());
37+
* </pre>
38+
*
39+
* @author Josh Cummings
40+
* @since 7.0
41+
* @see Saml2Jackson2Module
42+
* @see SecurityJackson2Modules
43+
*/
44+
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
45+
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
46+
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
47+
@JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true)
48+
class SimpleSaml2ResponseAssertionAccessorMixin {
49+
50+
@JsonCreator
51+
SimpleSaml2ResponseAssertionAccessorMixin(@JsonProperty("responseValue") String responseValue,
52+
@JsonProperty("nameId") String nameId, @JsonProperty("sessionIndexes") List<String> sessionIndexes,
53+
@JsonProperty("attributes") Map<String, List<Object>> attributes) {
54+
}
55+
56+
}

saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
*
3131
* @author Clement Stoquart
3232
* @since 5.4
33+
* @deprecated Please use {@link Saml2ResponseAssertionAccessor}
3334
*/
35+
@Deprecated
3436
public class DefaultSaml2AuthenticatedPrincipal implements Saml2AuthenticatedPrincipal, Serializable {
3537

3638
@Serial
@@ -58,6 +60,12 @@ public DefaultSaml2AuthenticatedPrincipal(String name, Map<String, List<Object>>
5860
this.sessionIndexes = sessionIndexes;
5961
}
6062

63+
public DefaultSaml2AuthenticatedPrincipal(String name, Saml2ResponseAssertionAccessor assertion) {
64+
this.name = name;
65+
this.attributes = assertion.getAttributes();
66+
this.sessionIndexes = assertion.getSessionIndexes();
67+
}
68+
6169
@Override
6270
public String getName() {
6371
return this.name;

0 commit comments

Comments
 (0)