浏览代码

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
Josh Cummings 4 月之前
父节点
当前提交
3869b13e68

+ 105 - 0
docs/modules/ROOT/pages/migration-7/saml2.adoc

@@ -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)
+----
+======

+ 195 - 0
docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc

@@ -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
 

+ 2 - 2
docs/modules/ROOT/pages/servlet/saml2/logout.adoc

@@ -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`

+ 22 - 9
saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/authentication/BaseOpenSamlAuthenticationProvider.java

@@ -110,6 +110,8 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 
 	private Converter<ResponseToken, ? extends AbstractAuthenticationToken> responseAuthenticationConverter = createDefaultResponseAuthenticationConverter();
 
+	private boolean validateResponseAfterAssertions = false;
+
 	private static final Set<String> includeChildStatusCodes = new HashSet<>(
 			Arrays.asList(StatusCode.REQUESTER, StatusCode.RESPONDER, StatusCode.VERSION_MISMATCH));
 
@@ -143,6 +145,10 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 		this.responseAuthenticationConverter = responseAuthenticationConverter;
 	}
 
+	void setValidateResponseAfterAssertions(boolean validateResponseAfterAssertions) {
+		this.validateResponseAfterAssertions = validateResponseAfterAssertions;
+	}
+
 	static Converter<ResponseToken, Saml2ResponseValidatorResult> createDefaultResponseValidator() {
 		return (responseToken) -> {
 			Response response = responseToken.getResponse();
@@ -321,7 +327,9 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 			result = result.concat(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE,
 					"Did not decrypt response [" + response.getID() + "] since it is not signed"));
 		}
-		result = result.concat(this.responseValidator.convert(responseToken));
+		if (!this.validateResponseAfterAssertions) {
+			result = result.concat(this.responseValidator.convert(responseToken));
+		}
 		boolean allAssertionsSigned = true;
 		for (Assertion assertion : response.getAssertions()) {
 			AssertionToken assertionToken = new AssertionToken(assertion, token);
@@ -337,11 +345,16 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 					+ "Please either sign the response or all of the assertions.";
 			result = result.concat(new Saml2Error(Saml2ErrorCodes.INVALID_SIGNATURE, description));
 		}
-		Assertion firstAssertion = CollectionUtils.firstElement(response.getAssertions());
-		if (firstAssertion != null && !hasName(firstAssertion)) {
-			Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
-					"Assertion [" + firstAssertion.getID() + "] is missing a subject");
-			result = result.concat(error);
+		if (this.validateResponseAfterAssertions) {
+			result = result.concat(this.responseValidator.convert(responseToken));
+		}
+		else {
+			Assertion firstAssertion = CollectionUtils.firstElement(response.getAssertions());
+			if (firstAssertion != null && !hasName(firstAssertion)) {
+				Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
+						"Assertion [" + firstAssertion.getID() + "] is missing a subject");
+				result = result.concat(error);
+			}
 		}
 
 		if (result.hasErrors()) {
@@ -422,7 +435,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 		};
 	}
 
-	private boolean hasName(Assertion assertion) {
+	static boolean hasName(Assertion assertion) {
 		if (assertion == null) {
 			return false;
 		}
@@ -435,7 +448,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 		return assertion.getSubject().getNameID().getValue() != null;
 	}
 
-	private static Map<String, List<Object>> getAssertionAttributes(Assertion assertion) {
+	static Map<String, List<Object>> getAssertionAttributes(Assertion assertion) {
 		MultiValueMap<String, Object> attributeMap = new LinkedMultiValueMap<>();
 		for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) {
 			for (Attribute attribute : attributeStatement.getAttributes()) {
@@ -452,7 +465,7 @@ class BaseOpenSamlAuthenticationProvider implements AuthenticationProvider {
 		return new LinkedHashMap<>(attributeMap); // gh-11785
 	}
 
-	private static List<String> getSessionIndexes(Assertion assertion) {
+	static List<String> getSessionIndexes(Assertion assertion) {
 		List<String> sessionIndexes = new ArrayList<>();
 		for (AuthnStatement statement : assertion.getAuthnStatements()) {
 			sessionIndexes.add(statement.getSessionIndex());

+ 1 - 0
saml2/saml2-service-provider/src/opensaml4Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml4AuthenticationProvider.java

@@ -85,6 +85,7 @@ public final class OpenSaml4AuthenticationProvider implements AuthenticationProv
 	 */
 	public OpenSaml4AuthenticationProvider() {
 		this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml4Template());
+		this.delegate.setValidateResponseAfterAssertions(false);
 	}
 
 	/**

+ 99 - 4
saml2/saml2-service-provider/src/opensaml5Main/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProvider.java

@@ -58,12 +58,15 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
 import org.springframework.security.authentication.AuthenticationProvider;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
 import org.springframework.security.saml2.core.Saml2Error;
 import org.springframework.security.saml2.core.Saml2ErrorCodes;
 import org.springframework.security.saml2.core.Saml2ResponseValidatorResult;
 import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata;
 import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
 import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
 /**
@@ -118,6 +121,7 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
 		this.delegate = new BaseOpenSamlAuthenticationProvider(new OpenSaml5Template());
 		setResponseValidator(ResponseValidator.withDefaults());
 		setAssertionValidator(AssertionValidator.withDefaults());
+		setResponseAuthenticationConverter(new ResponseAuthenticationConverter());
 	}
 
 	/**
@@ -300,6 +304,21 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
 				(token) -> responseAuthenticationConverter.convert(new ResponseToken(token)));
 	}
 
+	/**
+	 * Indicate when to validate response attributes, like {@code Destination} and
+	 * {@code Issuer}. By default, this value is set to false, meaning that response
+	 * attributes are validated first. Setting this value to {@code true} allows you to
+	 * use a response authentication converter that doesn't rely on the {@code NameID}
+	 * element in the {@link Response}'s assertion.
+	 * @param validateResponseAfterAssertions when to validate response attributes
+	 * @since 6.5
+	 * @see #setResponseAuthenticationConverter
+	 * @see ResponseAuthenticationConverter
+	 */
+	public void setValidateResponseAfterAssertions(boolean validateResponseAfterAssertions) {
+		this.delegate.setValidateResponseAfterAssertions(validateResponseAfterAssertions);
+	}
+
 	/**
 	 * Construct a default strategy for validating the SAML 2.0 Response
 	 * @return the default response validator strategy
@@ -373,12 +392,11 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
 	 * Construct a default strategy for converting a SAML 2.0 Response and
 	 * {@link Authentication} token into a {@link Saml2Authentication}
 	 * @return the default response authentication converter strategy
+	 * @deprecated please use {@link ResponseAuthenticationConverter} instead
 	 */
+	@Deprecated
 	public static Converter<ResponseToken, Saml2Authentication> createDefaultResponseAuthenticationConverter() {
-		Converter<BaseOpenSamlAuthenticationProvider.ResponseToken, Saml2Authentication> delegate = BaseOpenSamlAuthenticationProvider
-			.createDefaultResponseAuthenticationConverter();
-		return (token) -> delegate
-			.convert(new BaseOpenSamlAuthenticationProvider.ResponseToken(token.getResponse(), token.getToken()));
+		return new ResponseAuthenticationConverter();
 	}
 
 	/**
@@ -852,4 +870,81 @@ public final class OpenSaml5AuthenticationProvider implements AuthenticationProv
 
 	}
 
+	/**
+	 * A default implementation of {@link OpenSaml5AuthenticationProvider}'s response
+	 * authentication converter. It will take the principal name from the
+	 * {@link org.opensaml.saml.saml2.core.NameID} element. It will also extract the
+	 * assertion attributes and session indexes. You can either configure the principal
+	 * name converter and granted authorities converter in this class or you can
+	 * post-process this class's result through delegation.
+	 *
+	 * @author Josh Cummings
+	 * @since 6.5
+	 */
+	public static final class ResponseAuthenticationConverter implements Converter<ResponseToken, Saml2Authentication> {
+
+		private Converter<Assertion, String> principalNameConverter = ResponseAuthenticationConverter::authenticatedPrincipal;
+
+		private Converter<Assertion, Collection<GrantedAuthority>> grantedAuthoritiesConverter = ResponseAuthenticationConverter::grantedAuthorities;
+
+		@Override
+		public Saml2Authentication convert(ResponseToken responseToken) {
+			Response response = responseToken.response;
+			Saml2AuthenticationToken token = responseToken.token;
+			Assertion assertion = CollectionUtils.firstElement(response.getAssertions());
+			String username = this.principalNameConverter.convert(assertion);
+			Map<String, List<Object>> attributes = BaseOpenSamlAuthenticationProvider.getAssertionAttributes(assertion);
+			List<String> 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));
+		}
+
+		/**
+		 * Use this strategy to extract the principal name from the {@link Assertion}. By
+		 * default, this will retrieve it from the
+		 * {@link org.opensaml.saml.saml2.core.Subject}'s
+		 * {@link org.opensaml.saml.saml2.core.NameID} value.
+		 *
+		 * <p>
+		 * Note that because of this, if there is no
+		 * {@link org.opensaml.saml.saml2.core.NameID} present, then the default throws an
+		 * exception.
+		 * </p>
+		 * @param principalNameConverter the conversion strategy to use
+		 */
+		public void setPrincipalNameConverter(Converter<Assertion, String> principalNameConverter) {
+			Assert.notNull(principalNameConverter, "principalNameConverter cannot be null");
+			this.principalNameConverter = principalNameConverter;
+		}
+
+		/**
+		 * Use this strategy to grant authorities to a principal given the first
+		 * {@link Assertion} in the response. By default, this will grant
+		 * {@code ROLE_USER}.
+		 * @param grantedAuthoritiesConverter the conversion strategy to use
+		 */
+		public void setGrantedAuthoritiesConverter(
+				Converter<Assertion, Collection<GrantedAuthority>> grantedAuthoritiesConverter) {
+			Assert.notNull(grantedAuthoritiesConverter, "grantedAuthoritiesConverter cannot be null");
+			this.grantedAuthoritiesConverter = grantedAuthoritiesConverter;
+		}
+
+		private static String authenticatedPrincipal(Assertion assertion) {
+			if (!BaseOpenSamlAuthenticationProvider.hasName(assertion)) {
+				throw new Saml2AuthenticationException(new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
+						"Assertion [" + assertion.getID() + "] is missing a subject"));
+			}
+			return assertion.getSubject().getNameID().getValue();
+		}
+
+		private static Collection<GrantedAuthority> grantedAuthorities(Assertion assertion) {
+			return AuthorityUtils.createAuthorityList("ROLE_USER");
+		}
+
+	}
+
 }

+ 46 - 0
saml2/saml2-service-provider/src/opensaml5Test/java/org/springframework/security/saml2/provider/service/authentication/OpenSaml5AuthenticationProviderTests.java

@@ -22,6 +22,7 @@ import java.io.ObjectOutputStream;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -71,12 +72,15 @@ import org.opensaml.xmlsec.signature.support.SignatureConstants;
 
 import org.springframework.core.convert.converter.Converter;
 import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
 import org.springframework.security.jackson2.SecurityJackson2Modules;
 import org.springframework.security.saml2.core.Saml2Error;
 import org.springframework.security.saml2.core.Saml2ErrorCodes;
 import org.springframework.security.saml2.core.Saml2ResponseValidatorResult;
 import org.springframework.security.saml2.core.TestSaml2X509Credentials;
 import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.AssertionValidator;
+import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseAuthenticationConverter;
 import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseToken;
 import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider.ResponseValidator;
 import org.springframework.security.saml2.provider.service.authentication.TestCustomOpenSaml5Objects.CustomOpenSamlObject;
@@ -92,6 +96,7 @@ import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 /**
  * Tests for {@link OpenSaml5AuthenticationProvider}
@@ -660,6 +665,47 @@ public class OpenSaml5AuthenticationProviderTests {
 		verify(authenticationConverter).convert(any());
 	}
 
+	@Test
+	public void authenticateWhenResponseAuthenticationConverterComponentConfiguredThenUses() {
+		Converter<Assertion, Collection<GrantedAuthority>> grantedAuthoritiesConverter = mock(Converter.class);
+		given(grantedAuthoritiesConverter.convert(any())).willReturn(AuthorityUtils.createAuthorityList("CUSTOM"));
+		ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
+		authenticationConverter.setGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
+		OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
+		provider.setResponseAuthenticationConverter(authenticationConverter);
+		Response response = TestOpenSamlObjects.signedResponseWithOneAssertion();
+		Saml2AuthenticationToken token = token(response, verifying(registration()));
+		Authentication authentication = provider.authenticate(token);
+		assertThat(AuthorityUtils.authorityListToSet(authentication.getAuthorities())).containsExactly("CUSTOM");
+		verify(grantedAuthoritiesConverter).convert(any());
+	}
+
+	@Test
+	public void authenticateWhenValidateResponseAfterAssertionsThenCanHaveResponseAuthenticationConverterThatDoesntNeedANameID() {
+		Converter<ResponseToken, Saml2Authentication> responseAuthenticationConverter = mock(Converter.class);
+		OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
+		provider.setValidateResponseAfterAssertions(true);
+		provider.setResponseAuthenticationConverter(responseAuthenticationConverter);
+		Response response = TestOpenSamlObjects
+			.signedResponseWithOneAssertion((r) -> r.getAssertions().get(0).setSubject(null));
+		Saml2AuthenticationToken token = token(response, verifying(registration()));
+		provider.authenticate(token);
+		verify(responseAuthenticationConverter).convert(any());
+	}
+
+	@Test
+	public void authenticateWhenValidateResponseBeforeAssertionsThenMustHaveNameID() {
+		Converter<ResponseToken, Saml2Authentication> responseAuthenticationConverter = mock(Converter.class);
+		OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
+		provider.setValidateResponseAfterAssertions(false);
+		provider.setResponseAuthenticationConverter(responseAuthenticationConverter);
+		Response response = TestOpenSamlObjects
+			.signedResponseWithOneAssertion((r) -> r.getAssertions().get(0).setSubject(null));
+		Saml2AuthenticationToken token = token(response, verifying(registration()));
+		assertThatExceptionOfType(Saml2AuthenticationException.class).isThrownBy(() -> provider.authenticate(token));
+		verifyNoInteractions(responseAuthenticationConverter);
+	}
+
 	@Test
 	public void setResponseAuthenticationConverterWhenNullThenIllegalArgument() {
 		// @formatter:off