1
0
mirror of synced 2026-08-05 09:47:05 +00:00

Add ResponseAuthenticationConverter

Aside from simplifying configuration, this commit also makes it possible
to provide a response authentication converter that doesn't need the
NameID element to be present.

Closes gh-12136
This commit is contained in:
Josh Cummings
2025-04-07 16:35:28 -06:00
parent 3e686abf50
commit 3869b13e68
7 changed files with 470 additions and 15 deletions
@@ -58,3 +58,108 @@ Xml::
<b:bean id="saml2PostProcessor" class="org.example.MySaml2WebSsoAuthenticationFilterBeanPostProcessor"/>
----
======
== Validate Response After Validating Assertions
In Spring Security 6, the order of authenticating a `<saml2:Response>` is as follows:
1. Verify the Response Signature, if any
2. Decrypt the Response
3. Validate Response attributes, like Destination and Issuer
4. For each assertion, verify the signature, decrypt, and then validate its fields
5. Check to ensure that the response has at least one assertion with a name field
This ordering sometimes poses challenges since some response validation is being done in Step 3 and some in Step 5.
Specifically, this poses a chellenge when an application doesn't have a name field and doesn't need it to be validated.
In Spring Security 7, this is simplified by moving response validation to after assertion validation and combining the two separate validation steps 3 and 5.
When this is complete, response validation will no longer check for the existence of the `NameID` attribute and rely on ``ResponseAuthenticationConverter``s to do this.
This will add support ``ResponseAuthenticationConverter``s that don't use the `NameID` element in their `Authentication` instance and so don't need it validated.
To opt-in to this behavior in advance, use `OpenSaml5AuthenticationProvider#setValidateResponseAfterAssertions` to `true` like so:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
provider.setValidateResponseAfterAssertions(true);
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
val provider = OpenSaml5AuthenticationProvider()
provider.setValidateResponseAfterAssertions(true)
----
======
This will change the authentication steps as follows:
1. Verify the Response Signature, if any
2. Decrypt the Response
3. For each assertion, verify the signature, decrypt, and then validate its fields
4. Validate Response attributes, like Destination and Issuer
Note that if you have a custom response authentication converter, then you are now responsible to check if the `NameID` element exists in the event that you need it.
Alternatively to updating your response authentication converter, you can specify a custom `ResponseValidator` that adds back in the check for the `NameID` element as follows:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
provider.setValidateResponseAfterAssertions(true);
ResponseValidator responseValidator = ResponseValidator.withDefaults((responseToken) -> {
Response response = responseToken.getResponse();
Assertion assertion = CollectionUtils.firstElement(response.getAssertions());
Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
"Assertion [" + firstAssertion.getID() + "] is missing a subject");
Saml2ResponseValidationResult failed = Saml2ResponseValidationResult.failure(error);
if (assertion.getSubject() == null) {
return failed;
}
if (assertion.getSubject().getNameID() == null) {
return failed;
}
if (assertion.getSubject().getNameID().getValue() == null) {
return failed;
}
return Saml2ResponseValidationResult.success();
});
provider.setResponseValidator(responseValidator);
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
val provider = OpenSaml5AuthenticationProvider()
provider.setValidateResponseAfterAssertions(true)
val responseValidator = ResponseValidator.withDefaults { responseToken: ResponseToken ->
val response = responseToken.getResponse()
val assertion = CollectionUtils.firstElement(response.getAssertions())
val error = Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
"Assertion [" + firstAssertion.getID() + "] is missing a subject")
val failed = Saml2ResponseValidationResult.failure(error)
if (assertion.getSubject() == null) {
return@withDefaults failed
}
if (assertion.getSubject().getNameID() == null) {
return@withDefaults failed
}
if (assertion.getSubject().getNameID().getValue() == null) {
return@withDefaults failed
}
return@withDefaults Saml2ResponseValidationResult.success()
}
provider.setResponseValidator(responseValidator)
----
======
@@ -250,12 +250,135 @@ class SecurityConfig {
----
======
== Converting an `Assertion` into an `Authentication`
`OpenSamlXAuthenticationProvider#setResponseAuthenticationConverter` provides a way for you to change how it converts your assertion into an `Authentication` instance.
You can set a custom converter in the following way:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
Converter<ResponseToken, Saml2Authentication> authenticationConverter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
OpenSaml5AuthenticationProvider authenticationProvider = new OpenSaml5AuthenticationProvider();
authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter);
http
.authorizeHttpRequests((authz) -> authz
.anyRequest().authenticated())
.saml2Login((saml2) -> saml2
.authenticationManager(new ProviderManager(authenticationProvider))
);
return http.build();
}
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Configuration
@EnableWebSecurity
open class SecurityConfig {
@Autowired
var authenticationConverter: Converter<ResponseToken, Saml2Authentication>? = null
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
val authenticationProvider = OpenSaml5AuthenticationProvider()
authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter)
http {
authorizeRequests {
authorize(anyRequest, authenticated)
}
saml2Login {
authenticationManager = ProviderManager(authenticationProvider)
}
}
return http.build()
}
}
----
======
The ensuing examples all build off of this common construct to show you different ways this converter comes in handy.
[[servlet-saml2login-opensamlauthenticationprovider-userdetailsservice]]
== Coordinating with a `UserDetailsService`
Or, perhaps you would like to include user details from a legacy `UserDetailsService`.
In that case, the response authentication converter can come in handy, as can be seen below:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Component
class MyUserDetailsResponseAuthenticationConverter implements Converter<ResponseToken, Saml2Authentication> {
private final ResponseAuthenticationConverter delegate = new ResponseAuthenticationConverter();
private final UserDetailsService userDetailsService;
MyUserDetailsResponseAuthenticationConverter(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Override
public Saml2Authentication convert(ResponseToken responseToken) {
Saml2Authentication authentication = this.delegate.convert(responseToken); <1>
UserDetails principal = this.userDetailsService.loadByUsername(username); <2>
String saml2Response = authentication.getSaml2Response();
Collection<GrantedAuthority> authorities = principal.getAuthorities();
return new Saml2Authentication((AuthenticatedPrincipal) userDetails, saml2Response, authorities); <3>
}
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Component
open class MyUserDetailsResponseAuthenticationConverter(val delegate: ResponseAuthenticationConverter,
UserDetailsService userDetailsService): Converter<ResponseToken, Saml2Authentication> {
@Override
open fun convert(responseToken: ResponseToken): Saml2Authentication {
val authentication = this.delegate.convert(responseToken) <1>
val principal = this.userDetailsService.loadByUsername(username) <2>
val saml2Response = authentication.getSaml2Response()
val authorities = principal.getAuthorities()
return Saml2Authentication(userDetails as AuthenticatedPrincipal, saml2Response, authorities) <3>
}
}
----
======
<1> First, call the default converter, which extracts attributes and authorities from the response
<2> Second, call the xref:servlet/authentication/passwords/user-details-service.adoc#servlet-authentication-userdetailsservice[`UserDetailsService`] using the relevant information
<3> Third, return an authentication that includes the user details
[TIP]
====
If your `UserDetailsService` returns a value that also implements `AuthenticatedPrincipal`, then you don't need a custom authentication implementation.
====
Or, if you are using OpenSaml 4, then you can achieve something similar as follows:
[tabs]
======
Java::
@@ -336,6 +459,78 @@ open class SecurityConfig {
It's not required to call ``OpenSaml4AuthenticationProvider``'s default authentication converter.
It returns a `Saml2AuthenticatedPrincipal` containing the attributes it extracted from ``AttributeStatement``s as well as the single `ROLE_USER` authority.
=== Configuring the Principal Name
Sometimes, the principal name is not in the `<saml2:NameID>` element.
In that case, you can configure the `ResponseAuthenticationConverter` with a custom strategy like so:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
ResponseAuthenticationConverter authenticationConverter() {
ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
authenticationConverter.setPrincipalNameConverter((assertion) -> {
// ... work with OpenSAML's Assertion object to extract the principal
});
return authenticationConverter;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun authenticationConverter(): ResponseAuthenticationConverter {
val authenticationConverter: ResponseAuthenticationConverter = ResponseAuthenticationConverter()
authenticationConverter.setPrincipalNameConverter { assertion ->
// ... work with OpenSAML's Assertion object to extract the principal
}
return authenticationConverter
}
----
======
=== Configuring a Principal's Granted Authorities
Spring Security automatically grants `ROLE_USER` when using `OpenSamlXAuhenticationProvider`.
With `OpenSaml5AuthenticationProvider`, you can configure a different set of granted authorities like so:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
ResponseAuthenticationConverter authenticationConverter() {
ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
authenticationConverter.setPrincipalNameConverter((assertion) -> {
// ... grant the needed authorities based on attributes in the assertion
});
return authenticationConverter;
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun authenticationConverter(): ResponseAuthenticationConverter {
val authenticationConverter = ResponseAuthenticationConverter()
authenticationConverter.setPrincipalNameConverter{ assertion ->
// ... grant the needed authorities based on attributes in the assertion
}
return authenticationConverter
}
----
======
[[servlet-saml2login-opensamlauthenticationprovider-additionalvalidation]]
== Performing Additional Response Validation
@@ -339,7 +339,7 @@ It's common to need to set other values in the `<saml2:LogoutRequest>` than the
By default, Spring Security will issue a `<saml2:LogoutRequest>` and supply:
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation`
* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation`
* The `ID` attribute - a GUID
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
* The `<NameID>` element - from `Authentication#getName`
@@ -424,7 +424,7 @@ It's common to need to set other values in the `<saml2:LogoutResponse>` than the
By default, Spring Security will issue a `<saml2:LogoutResponse>` and supply:
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation`
* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation`
* The `ID` attribute - a GUID
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
* The `<Status>` element - `SUCCESS`