diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java index e69f8c825b6..fc9950d9649 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LogoutConfigurer.java @@ -34,6 +34,8 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml4LogoutRequestValidator; import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml4LogoutResponseValidator; import org.springframework.security.saml2.provider.service.authentication.logout.OpenSaml5LogoutRequestValidator; @@ -534,7 +536,13 @@ public boolean matches(HttpServletRequest request) { if (authentication == null) { return false; } - return authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal; + if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal) { + return true; + } + if (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor) { + return true; + } + return authentication instanceof Saml2Authentication; } } diff --git a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java index bef7a69d195..749127027cc 100644 --- a/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java +++ b/config/src/main/java/org/springframework/security/config/http/Saml2LogoutBeanDefinitionParser.java @@ -32,6 +32,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver; import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestFilter; import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseFilter; @@ -239,7 +241,13 @@ public boolean matches(HttpServletRequest request) { if (authentication == null) { return false; } - return authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal; + if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal) { + return true; + } + if (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor) { + return true; + } + return authentication instanceof Saml2Authentication; } public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { diff --git a/config/src/test/java/org/springframework/security/SerializationSamples.java b/config/src/test/java/org/springframework/security/SerializationSamples.java index 396828ca33e..d120a6ebd0c 100644 --- a/config/src/test/java/org/springframework/security/SerializationSamples.java +++ b/config/src/test/java/org/springframework/security/SerializationSamples.java @@ -170,11 +170,14 @@ import org.springframework.security.saml2.core.Saml2X509Credential; import org.springframework.security.saml2.credentials.TestSaml2X509Credentials; import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken; import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2RedirectAuthenticationRequest; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; import org.springframework.security.saml2.provider.service.authentication.TestSaml2AuthenticationTokens; import org.springframework.security.saml2.provider.service.authentication.TestSaml2Authentications; import org.springframework.security.saml2.provider.service.authentication.TestSaml2LogoutRequests; @@ -520,8 +523,16 @@ final class SerializationSamples { generatorByClassName.put(Saml2Exception.class, (r) -> new Saml2Exception("message", new IOException("fail"))); generatorByClassName.put(DefaultSaml2AuthenticatedPrincipal.class, (r) -> TestSaml2Authentications.authentication().getPrincipal()); - generatorByClassName.put(Saml2Authentication.class, - (r) -> applyDetails(TestSaml2Authentications.authentication())); + Saml2Authentication saml2 = TestSaml2Authentications.authentication(); + generatorByClassName.put(Saml2Authentication.class, (r) -> applyDetails(saml2)); + Saml2ResponseAssertionAccessor assertion = Saml2ResponseAssertion.withResponseValue("response") + .nameId("name") + .sessionIndexes(List.of("id")) + .attributes(Map.of("key", List.of("value"))) + .build(); + generatorByClassName.put(Saml2ResponseAssertion.class, (r) -> assertion); + generatorByClassName.put(Saml2AssertionAuthentication.class, (r) -> applyDetails( + new Saml2AssertionAuthentication(assertion, authentication.getAuthorities(), "id"))); generatorByClassName.put(Saml2PostAuthenticationRequest.class, (r) -> TestSaml2PostAuthenticationRequests.create()); generatorByClassName.put(Saml2RedirectAuthenticationRequest.class, diff --git a/config/src/test/resources/serialized/7.0.x/org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication.serialized b/config/src/test/resources/serialized/7.0.x/org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication.serialized new file mode 100644 index 00000000000..61898671f84 Binary files /dev/null and b/config/src/test/resources/serialized/7.0.x/org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication.serialized differ diff --git a/config/src/test/resources/serialized/7.0.x/org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion.serialized b/config/src/test/resources/serialized/7.0.x/org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion.serialized new file mode 100644 index 00000000000..0bc93ea8af5 Binary files /dev/null and b/config/src/test/resources/serialized/7.0.x/org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion.serialized differ diff --git a/docs/modules/ROOT/pages/migration/servlet/saml2.adoc b/docs/modules/ROOT/pages/migration/servlet/saml2.adoc index c87597aafbf..8ca5cc3d552 100644 --- a/docs/modules/ROOT/pages/migration/servlet/saml2.adoc +++ b/docs/modules/ROOT/pages/migration/servlet/saml2.adoc @@ -54,3 +54,57 @@ fun logoutResponseResolver(registrations: RelyingPartyRegistrationRepository?): ---- ====== +== Favor `Saml2ResponseAuthenticationAccessor` over `Saml2AuthenticatedPrincipal` + +Spring Security 7 separates `` details from the principal. +This allows Spring Security to retrieve needed assertion details to perform Single Logout. + +This deprecates `Saml2AuthenticatedPrincipal`. +You no longer need to implement it to use `Saml2Authentication`. + +Instead, the credential implements `Saml2ResponseAssertionAccessor`, which Spring Security 7 favors when determining the appropriate action based on the authentication. + +This change is made automatically for you when using the defaults. + +If this causes you trouble when upgrading, you can publish a custom `ResponseAuhenticationConverter` to return a `Saml2Authentication` instead of returning a `Saml2AssertionAuthentication` like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +OpenSaml5AuthenticationProvider authenticationProvider() { + OpenSaml5AuthenticationProvider authenticationProvider = + new OpenSaml5AuthenticationProvider(); + ResponseAuthenticationConverter defaults = new ResponseAuthenticationConverter(); + authenticationProvider.setResponseAuthenticationConverter( + defaults.andThen((authentication) -> new Saml2Authentication( + authentication.getPrincipal(), + authentication.getSaml2Response(), + authentication.getAuthorities()))); + return authenticationProvider; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun authenticationProvider(): OpenSaml5AuthenticationProvider { + val authenticationProvider = OpenSaml5AuthenticationProvider() + val defaults = ResponseAuthenticationConverter() + authenticationProvider.setResponseAuthenticationConverter( + defaults.andThen { authentication -> + Saml2Authentication(authentication.getPrincipal(), + authentication.getSaml2Response(), + authentication.getAuthorities()) + }) + return authenticationProvider +} +---- +====== + +If you are constructing a `Saml2Authentication` instance yourself, consider changing to `Saml2AssertionAuthentication` to get the same benefit as the current default. diff --git a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc index 1c517914dc9..400a66ad3ac 100644 --- a/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc +++ b/docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc @@ -341,8 +341,10 @@ class MyUserDetailsResponseAuthenticationConverter implements Converter UserDetails principal = this.userDetailsService.loadByUsername(username); <2> String saml2Response = authentication.getSaml2Response(); + Saml2ResponseAssertionAccessor assertion = new OpenSamlResponseAssertionAccessor( + saml2Response, CollectionUtils.getFirst(response.getAssertions())); Collection authorities = principal.getAuthorities(); - return new Saml2Authentication((AuthenticatedPrincipal) userDetails, saml2Response, authorities); <3> + return new Saml2AssertionAuthentication(userDetails, assertion, authorities); <3> } } @@ -361,8 +363,10 @@ open class MyUserDetailsResponseAuthenticationConverter(val delegate: ResponseAu val authentication = this.delegate.convert(responseToken) <1> val principal = this.userDetailsService.loadByUsername(username) <2> val saml2Response = authentication.getSaml2Response() + val assertion = OpenSamlResponseAssertionAccessor( + saml2Response, CollectionUtils.getFirst(response.getAssertions())) val authorities = principal.getAuthorities() - return Saml2Authentication(userDetails as AuthenticatedPrincipal, saml2Response, authorities) <3> + return Saml2AssertionAuthentication(userDetails, assertion, authorities) <3> } } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2AssertionAuthenticationMixin.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2AssertionAuthenticationMixin.java new file mode 100644 index 00000000000..d3d31564469 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2AssertionAuthenticationMixin.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.security.saml2.jackson2; + +import java.util.Collection; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; + +/** + * Jackson Mixin class helps in serialize/deserialize + * {@link Saml2AssertionAuthentication}. + * + *
+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new Saml2Jackson2Module());
+ * 
+ * + * @author Josh Cummings + * @since 7.0 + * @see Saml2Jackson2Module + * @see SecurityJackson2Modules + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true) +class Saml2AssertionAuthenticationMixin { + + @JsonCreator + Saml2AssertionAuthenticationMixin(@JsonProperty("principal") Object principal, + @JsonProperty("assertion") Saml2ResponseAssertionAccessor assertion, + @JsonProperty("authorities") Collection authorities, + @JsonProperty("relyingPartyRegistrationId") String relyingPartyRegistrationId) { + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java index 3d99fc2cfa7..b53fcd15bc0 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/Saml2Jackson2Module.java @@ -22,10 +22,12 @@ import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.provider.service.authentication.DefaultSaml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.Saml2RedirectAuthenticationRequest; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion; import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; /** @@ -49,6 +51,8 @@ public Saml2Jackson2Module() { @Override public void setupModule(SetupContext context) { context.setMixInAnnotations(Saml2Authentication.class, Saml2AuthenticationMixin.class); + context.setMixInAnnotations(Saml2AssertionAuthentication.class, Saml2AssertionAuthenticationMixin.class); + context.setMixInAnnotations(Saml2ResponseAssertion.class, SimpleSaml2ResponseAssertionAccessorMixin.class); context.setMixInAnnotations(DefaultSaml2AuthenticatedPrincipal.class, DefaultSaml2AuthenticatedPrincipalMixin.class); context.setMixInAnnotations(Saml2LogoutRequest.class, Saml2LogoutRequestMixin.class); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/SimpleSaml2ResponseAssertionAccessorMixin.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/SimpleSaml2ResponseAssertionAccessorMixin.java new file mode 100644 index 00000000000..7f4d02b4a03 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/jackson2/SimpleSaml2ResponseAssertionAccessorMixin.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.security.saml2.jackson2; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertion; + +/** + * Jackson Mixin class helps in serialize/deserialize {@link Saml2ResponseAssertion}. + * + *
+ *     ObjectMapper mapper = new ObjectMapper();
+ *     mapper.registerModule(new Saml2Jackson2Module());
+ * 
+ * + * @author Josh Cummings + * @since 7.0 + * @see Saml2Jackson2Module + * @see SecurityJackson2Modules + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true) +class SimpleSaml2ResponseAssertionAccessorMixin { + + @JsonCreator + SimpleSaml2ResponseAssertionAccessorMixin(@JsonProperty("responseValue") String responseValue, + @JsonProperty("nameId") String nameId, @JsonProperty("sessionIndexes") List sessionIndexes, + @JsonProperty("attributes") Map> attributes) { + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java index d99f640cb53..d2e9658fd04 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipal.java @@ -30,7 +30,9 @@ * * @author Clement Stoquart * @since 5.4 + * @deprecated Please use {@link Saml2ResponseAssertionAccessor} */ +@Deprecated public class DefaultSaml2AuthenticatedPrincipal implements Saml2AuthenticatedPrincipal, Serializable { @Serial @@ -58,6 +60,12 @@ public DefaultSaml2AuthenticatedPrincipal(String name, Map> this.sessionIndexes = sessionIndexes; } + public DefaultSaml2AuthenticatedPrincipal(String name, Saml2ResponseAssertionAccessor assertion) { + this.name = name; + this.attributes = assertion.getAttributes(); + this.sessionIndexes = assertion.getSessionIndexes(); + } + @Override public String getName() { return this.name; diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java new file mode 100644 index 00000000000..c38cfbfbd14 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AssertionAuthentication.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.security.saml2.provider.service.authentication; + +import java.io.Serial; +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; + +/** + * An authentication based off of a SAML 2.0 Assertion + * + * @author Josh Cummings + * @since 7.0 + * @see Saml2ResponseAssertionAccessor + * @see Saml2ResponseAssertion + */ +public class Saml2AssertionAuthentication extends Saml2Authentication { + + @Serial + private static final long serialVersionUID = -4194323643788693205L; + + private final Saml2ResponseAssertionAccessor assertion; + + private final String relyingPartyRegistrationId; + + public Saml2AssertionAuthentication(Saml2ResponseAssertionAccessor assertion, + Collection authorities, String relyingPartyRegistrationId) { + super(assertion, assertion.getResponseValue(), authorities); + this.assertion = assertion; + this.relyingPartyRegistrationId = relyingPartyRegistrationId; + } + + public Saml2AssertionAuthentication(Object principal, Saml2ResponseAssertionAccessor assertion, + Collection authorities, String relyingPartyRegistrationId) { + super(principal, assertion.getResponseValue(), authorities); + this.assertion = assertion; + this.relyingPartyRegistrationId = relyingPartyRegistrationId; + setAuthenticated(true); + } + + @Override + public Saml2ResponseAssertionAccessor getCredentials() { + return this.assertion; + } + + public String getRelyingPartyRegistrationId() { + return this.relyingPartyRegistrationId; + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java index 205c5f8941c..6f3e32c8886 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2AuthenticatedPrincipal.java @@ -30,7 +30,11 @@ * * @author Clement Stoquart * @since 5.2.2 + * @deprecated Please use + * {@link Saml2AssertionAuthentication#getRelyingPartyRegistrationId()} and + * {@link Saml2ResponseAssertionAccessor} instead */ +@Deprecated public interface Saml2AuthenticatedPrincipal extends AuthenticatedPrincipal { /** diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java index 2292f52a378..e628f260e8e 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2Authentication.java @@ -41,7 +41,7 @@ public class Saml2Authentication extends AbstractAuthenticationToken { @Serial private static final long serialVersionUID = 405897702378720477L; - private final AuthenticatedPrincipal principal; + private final Object principal; private final String saml2Response; @@ -61,6 +61,14 @@ public Saml2Authentication(AuthenticatedPrincipal principal, String saml2Respons setAuthenticated(true); } + public Saml2Authentication(Object principal, String saml2Response, + Collection authorities) { + super(authorities); + this.principal = principal; + this.saml2Response = saml2Response; + setAuthenticated(true); + } + @Override public Object getPrincipal() { return this.principal; diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ResponseAssertion.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ResponseAssertion.java new file mode 100644 index 00000000000..ea5dd2a8a9f --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ResponseAssertion.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.security.saml2.provider.service.authentication; + +import java.io.Serial; +import java.util.List; +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * An OpenSAML-based implementation of {@link Saml2ResponseAssertionAccessor} + * + * @author Josh Cummings + * @since 7.0 + */ +public class Saml2ResponseAssertion implements Saml2ResponseAssertionAccessor { + + @Serial + private static final long serialVersionUID = -7505233045395024212L; + + private final String responseValue; + + private final String nameId; + + private final List sessionIndexes; + + private final Map> attributes; + + Saml2ResponseAssertion(String responseValue, String nameId, List sessionIndexes, + Map> attributes) { + Assert.notNull(responseValue, "response value cannot be null"); + Assert.notNull(nameId, "nameId cannot be null"); + Assert.notNull(sessionIndexes, "sessionIndexes cannot be null"); + Assert.notNull(attributes, "attributes cannot be null"); + this.responseValue = responseValue; + this.nameId = nameId; + this.sessionIndexes = sessionIndexes; + this.attributes = attributes; + } + + public static Builder withResponseValue(String responseValue) { + return new Builder(responseValue); + } + + @Override + public String getNameId() { + return this.nameId; + } + + @Override + public List getSessionIndexes() { + return this.sessionIndexes; + } + + @Override + public Map> getAttributes() { + return this.attributes; + } + + @Override + public String getResponseValue() { + return this.responseValue; + } + + public static final class Builder { + + private final String responseValue; + + private String nameId; + + private List sessionIndexes = List.of(); + + private Map> attributes = Map.of(); + + Builder(String responseValue) { + this.responseValue = responseValue; + } + + public Builder nameId(String nameId) { + this.nameId = nameId; + return this; + } + + public Builder sessionIndexes(List sessionIndexes) { + this.sessionIndexes = sessionIndexes; + return this; + } + + public Builder attributes(Map> attributes) { + this.attributes = attributes; + return this; + } + + public Saml2ResponseAssertion build() { + return new Saml2ResponseAssertion(this.responseValue, this.nameId, this.sessionIndexes, this.attributes); + } + + } + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ResponseAssertionAccessor.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ResponseAssertionAccessor.java new file mode 100644 index 00000000000..f137b798489 --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/Saml2ResponseAssertionAccessor.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * 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 + * + * https://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.springframework.security.saml2.provider.service.authentication; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.CollectionUtils; + +/** + * An interface that represents key details from a SAML 2.0 Assertion + * + * @author Josh Cummings + * @since 7.0 + * @see Saml2ResponseAssertion + */ +public interface Saml2ResponseAssertionAccessor extends Serializable { + + String getNameId(); + + List getSessionIndexes(); + + /** + * Get the first value of Saml2 token attribute by name + * @param name the name of the attribute + * @param the type of the attribute + * @return the first attribute value or {@code null} otherwise + */ + @Nullable default A getFirstAttribute(String name) { + List values = getAttribute(name); + return CollectionUtils.firstElement(values); + } + + /** + * Get the Saml2 token attribute by name + * @param name the name of the attribute + * @param the type of the attribute + * @return the attribute or {@code null} otherwise + */ + @Nullable default List getAttribute(String name) { + return (List) getAttributes().get(name); + } + + Map> getAttributes(); + + String getResponseValue(); + +} diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java index b96cb947c68..91651136840 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/logout/BaseOpenSamlLogoutRequestValidator.java @@ -27,6 +27,7 @@ import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlOperations.VerificationConfigurer; import org.springframework.security.saml2.provider.service.authentication.logout.OpenSamlOperations.VerificationConfigurer.RedirectParameters; import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata; @@ -142,8 +143,9 @@ private NameID getNameId(LogoutRequest request, RelyingPartyRegistration registr } private void validateNameId(NameID nameId, Authentication authentication, Collection errors) { - String name = nameId.getValue(); - if (!name.equals(authentication.getName())) { + String name = (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor assertion) + ? assertion.getNameId() : authentication.getName(); + if (!nameId.getValue().equals(name)) { errors.add(new Saml2Error(Saml2ErrorCodes.INVALID_REQUEST, "Failed to match subject in LogoutRequest with currently logged in user")); } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestResolver.java index 980cfa4ac66..def1ab7afee 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestResolver.java @@ -42,7 +42,9 @@ import org.springframework.security.core.Authentication; import org.springframework.security.saml2.core.OpenSamlInitializationService; import org.springframework.security.saml2.core.Saml2ParameterNames; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2ResponseAssertionAccessor; import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; @@ -147,11 +149,22 @@ public Saml2LogoutRequest resolve(HttpServletRequest request, Authentication aut issuer.setValue(entityId); logoutRequest.setIssuer(issuer); NameID nameId = this.nameIdBuilder.buildObject(); - nameId.setValue(authentication.getName()); logoutRequest.setNameID(nameId); - if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal) { - Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal(); - for (String index : principal.getSessionIndexes()) { + if (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor info) { + nameId.setValue(info.getNameId()); + } + else { + nameId.setValue(authentication.getName()); + } + if (authentication.getCredentials() instanceof Saml2ResponseAssertionAccessor info) { + for (String index : info.getSessionIndexes()) { + SessionIndex sessionIndex = this.sessionIndexBuilder.buildObject(); + sessionIndex.setValue(index); + logoutRequest.getSessionIndexes().add(sessionIndex); + } + } + else if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal info) { + for (String index : info.getSessionIndexes()) { SessionIndex sessionIndex = this.sessionIndexBuilder.buildObject(); sessionIndex.setValue(index); logoutRequest.getSessionIndexes().add(sessionIndex); @@ -194,9 +207,11 @@ private String getRegistrationId(Authentication authentication) { if (authentication == null) { return null; } - Object principal = authentication.getPrincipal(); - if (principal instanceof Saml2AuthenticatedPrincipal) { - return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId(); + if (authentication instanceof Saml2AssertionAuthentication response) { + return response.getRelyingPartyRegistrationId(); + } + if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal principal) { + return principal.getRelyingPartyRegistrationId(); } return null; } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestValidatorParametersResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestValidatorParametersResolver.java index 84a53005aa1..3fc54e88bfe 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestValidatorParametersResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutRequestValidatorParametersResolver.java @@ -24,6 +24,7 @@ import org.springframework.security.saml2.core.OpenSamlInitializationService; import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ParameterNames; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; @@ -133,8 +134,11 @@ private String getRegistrationId(RequestMatcher.MatchResult result, Authenticati if (authentication == null) { return null; } - if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal principal) { - return principal.getRelyingPartyRegistrationId(); + if (authentication instanceof Saml2AssertionAuthentication saml2) { + return saml2.getRelyingPartyRegistrationId(); + } + if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal saml2) { + return saml2.getRelyingPartyRegistrationId(); } return null; } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutResponseResolver.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutResponseResolver.java index 4033e4d4536..48dd0bc47ac 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutResponseResolver.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/BaseOpenSamlLogoutResponseResolver.java @@ -46,6 +46,7 @@ import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutResponse; @@ -220,9 +221,11 @@ private String getRegistrationId(Authentication authentication) { if (authentication == null) { return null; } - Object principal = authentication.getPrincipal(); - if (principal instanceof Saml2AuthenticatedPrincipal) { - return ((Saml2AuthenticatedPrincipal) principal).getRelyingPartyRegistrationId(); + if (authentication instanceof Saml2AssertionAuthentication saml2) { + return saml2.getRelyingPartyRegistrationId(); + } + if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal saml2) { + return saml2.getRelyingPartyRegistrationId(); } return null; } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java index a3a22568df2..29fe0921c5c 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/web/authentication/logout/Saml2LogoutRequestFilter.java @@ -33,6 +33,7 @@ import org.springframework.security.saml2.core.Saml2Error; import org.springframework.security.saml2.core.Saml2ErrorCodes; import org.springframework.security.saml2.core.Saml2ParameterNames; +import org.springframework.security.saml2.provider.service.authentication.Saml2AssertionAuthentication; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; import org.springframework.security.saml2.provider.service.authentication.logout.Saml2LogoutRequest; @@ -332,8 +333,11 @@ private String getRegistrationId(RequestMatcher.MatchResult result, Authenticati if (authentication == null) { return null; } - if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal principal) { - return principal.getRelyingPartyRegistrationId(); + if (authentication instanceof Saml2AssertionAuthentication saml2) { + return saml2.getRelyingPartyRegistrationId(); + } + if (authentication.getPrincipal() instanceof Saml2AuthenticatedPrincipal saml2) { + return saml2.getRelyingPartyRegistrationId(); } return null; } diff --git a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java index d2dbb5ba0c1..6d49c6ef2f3 100644 --- a/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java +++ b/saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java @@ -893,14 +893,15 @@ public Saml2Authentication convert(ResponseToken responseToken) { Saml2AuthenticationToken token = responseToken.token; Assertion assertion = CollectionUtils.firstElement(response.getAssertions()); String username = this.principalNameConverter.convert(assertion); - Map> attributes = BaseOpenSamlAuthenticationProvider.getAssertionAttributes(assertion); - List sessionIndexes = BaseOpenSamlAuthenticationProvider.getSessionIndexes(assertion); - DefaultSaml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(username, attributes, - sessionIndexes); String registrationId = responseToken.token.getRelyingPartyRegistration().getRegistrationId(); - principal.setRelyingPartyRegistrationId(registrationId); - return new Saml2Authentication(principal, token.getSaml2Response(), - this.grantedAuthoritiesConverter.convert(assertion)); + Saml2ResponseAssertionAccessor accessor = Saml2ResponseAssertion.withResponseValue(token.getSaml2Response()) + .nameId(authenticatedPrincipal(assertion)) + .sessionIndexes(BaseOpenSamlAuthenticationProvider.getSessionIndexes(assertion)) + .attributes(BaseOpenSamlAuthenticationProvider.getAssertionAttributes(assertion)) + .build(); + Saml2AuthenticatedPrincipal principal = new DefaultSaml2AuthenticatedPrincipal(username, accessor); + Collection authorities = this.grantedAuthoritiesConverter.convert(assertion); + return new Saml2AssertionAuthentication(principal, accessor, authorities, registrationId); } /** diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipalTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipalTests.java index 4ea06059a69..75ab69d1083 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipalTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/authentication/DefaultSaml2AuthenticatedPrincipalTests.java @@ -48,7 +48,8 @@ public void createDefaultSaml2AuthenticatedPrincipalWhenNameNullThenException() @Test public void createDefaultSaml2AuthenticatedPrincipalWhenAttributesNullThenException() { - assertThatIllegalArgumentException().isThrownBy(() -> new DefaultSaml2AuthenticatedPrincipal("user", null)) + assertThatIllegalArgumentException() + .isThrownBy(() -> new DefaultSaml2AuthenticatedPrincipal("user", (Map>) null)) .withMessageContaining("attributes cannot be null"); }