Browse Source

Make Public Missing Authority AccessDeniedHandler

Issue gh-17934
Josh Cummings 2 months ago
parent
commit
9f317757c3
12 changed files with 451 additions and 287 deletions
  1. 41 198
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java
  2. 4 1
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java
  3. 4 2
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java
  4. 5 2
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java
  5. 3 2
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java
  6. 8 5
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java
  7. 2 4
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java
  8. 4 62
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java
  9. 8 5
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java
  10. 4 6
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java
  11. 223 0
      web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java
  12. 145 0
      web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java

+ 41 - 198
config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java

@@ -16,40 +16,26 @@
 
 package org.springframework.security.config.annotation.web.configurers;
 
-import java.io.IOException;
-import java.util.Collection;
 import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.function.Consumer;
 
-import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
 import org.jspecify.annotations.Nullable;
 
-import org.springframework.security.access.AccessDeniedException;
-import org.springframework.security.authentication.InsufficientAuthenticationException;
-import org.springframework.security.authorization.AuthorityAuthorizationDecision;
-import org.springframework.security.authorization.AuthorizationDeniedException;
 import org.springframework.security.config.Customizer;
 import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.access.AccessDeniedHandler;
 import org.springframework.security.web.access.AccessDeniedHandlerImpl;
+import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
 import org.springframework.security.web.access.ExceptionTranslationFilter;
 import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler;
 import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
 import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint;
 import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
-import org.springframework.security.web.savedrequest.NullRequestCache;
 import org.springframework.security.web.savedrequest.RequestCache;
-import org.springframework.security.web.util.ThrowableAnalyzer;
-import org.springframework.security.web.util.matcher.AnyRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
-import org.springframework.util.Assert;
 
 /**
  * Adds exception handling for Spring Security related exceptions to an application. All
@@ -94,7 +80,8 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 
 	private LinkedHashMap<RequestMatcher, AccessDeniedHandler> defaultDeniedHandlerMappings = new LinkedHashMap<>();
 
-	private Map<String, LinkedHashMap<RequestMatcher, AuthenticationEntryPoint>> entryPoints = new LinkedHashMap<>();
+	private final DelegatingMissingAuthorityAccessDeniedHandler.Builder missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler
+		.builder();
 
 	/**
 	 * Creates a new instance
@@ -146,6 +133,37 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 		return this;
 	}
 
+	/**
+	 * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being
+	 * invoked for the provided missing {@link GrantedAuthority}.
+	 * @param entryPoint the {@link AuthenticationEntryPoint} to use for the given
+	 * {@code authority}
+	 * @param authority the authority
+	 * @return the {@link ExceptionHandlingConfigurer} for further customizations
+	 * @since 7.0
+	 */
+	public ExceptionHandlingConfigurer<H> defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint,
+			String authority) {
+		this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority);
+		return this;
+	}
+
+	/**
+	 * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being
+	 * invoked for the provided missing {@link GrantedAuthority}.
+	 * @param entryPoint a consumer of a
+	 * {@link DelegatingAuthenticationEntryPoint.Builder} to use for the given
+	 * {@code authority}
+	 * @param authority the authority
+	 * @return the {@link ExceptionHandlingConfigurer} for further customizations
+	 * @since 7.0
+	 */
+	public ExceptionHandlingConfigurer<H> defaultAuthenticationEntryPointFor(
+			Consumer<DelegatingAuthenticationEntryPoint.Builder> entryPoint, String authority) {
+		this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority);
+		return this;
+	}
+
 	/**
 	 * Sets the {@link AuthenticationEntryPoint} to be used.
 	 *
@@ -189,26 +207,6 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 		return this;
 	}
 
-	public ExceptionHandlingConfigurer<H> defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint,
-			RequestMatcher preferredMatcher, String authority) {
-		this.defaultEntryPointMappings.put(preferredMatcher, entryPoint);
-		LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> byMatcher = this.entryPoints.get(authority);
-		if (byMatcher == null) {
-			byMatcher = new LinkedHashMap<>();
-		}
-		byMatcher.put(preferredMatcher, entryPoint);
-		this.entryPoints.put(authority, byMatcher);
-		return this;
-	}
-
-	public ExceptionHandlingConfigurer<H> defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint,
-			String authority) {
-		LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> byMatcher = new LinkedHashMap<>();
-		byMatcher.put(AnyRequestMatcher.INSTANCE, entryPoint);
-		this.entryPoints.put(authority, byMatcher);
-		return this;
-	}
-
 	/**
 	 * Gets any explicitly configured {@link AuthenticationEntryPoint}
 	 * @return
@@ -269,22 +267,10 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 
 	private AccessDeniedHandler createDefaultDeniedHandler(H http) {
 		AccessDeniedHandler defaults = createDefaultAccessDeniedHandler(http);
-		if (this.entryPoints.isEmpty()) {
-			return defaults;
-		}
-		Map<String, AccessDeniedHandler> deniedHandlers = new LinkedHashMap<>();
-		for (Map.Entry<String, LinkedHashMap<RequestMatcher, AuthenticationEntryPoint>> entry : this.entryPoints
-			.entrySet()) {
-			AuthenticationEntryPoint entryPoint = entryPointFrom(entry.getValue());
-			AuthenticationEntryPointAccessDeniedHandlerAdapter deniedHandler = new AuthenticationEntryPointAccessDeniedHandlerAdapter(
-					entryPoint);
-			RequestCache requestCache = http.getSharedObject(RequestCache.class);
-			if (requestCache != null) {
-				deniedHandler.setRequestCache(requestCache);
-			}
-			deniedHandlers.put(entry.getKey(), deniedHandler);
-		}
-		return new AuthenticationFactorDelegatingAccessDeniedHandler(deniedHandlers, defaults);
+		DelegatingMissingAuthorityAccessDeniedHandler deniedHandler = this.missingAuthoritiesHandlerBuilder.build();
+		deniedHandler.setRequestCache(getRequestCache(http));
+		deniedHandler.setDefaultAccessDeniedHandler(defaults);
+		return deniedHandler;
 	}
 
 	private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) {
@@ -299,29 +285,10 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 	}
 
 	private AuthenticationEntryPoint createDefaultEntryPoint(H http) {
-		AuthenticationEntryPoint defaults = entryPointFrom(this.defaultEntryPointMappings);
-		if (this.entryPoints.isEmpty()) {
-			return defaults;
-		}
-		Map<String, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
-		for (Map.Entry<String, LinkedHashMap<RequestMatcher, AuthenticationEntryPoint>> entry : this.entryPoints
-			.entrySet()) {
-			entryPoints.put(entry.getKey(), entryPointFrom(entry.getValue()));
-		}
-		return new AuthenticationFactorDelegatingAuthenticationEntryPoint(entryPoints, defaults);
-	}
-
-	private AuthenticationEntryPoint entryPointFrom(
-			LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints) {
-		if (entryPoints.isEmpty()) {
+		if (this.defaultEntryPoint == null) {
 			return new Http403ForbiddenEntryPoint();
 		}
-		if (entryPoints.size() == 1) {
-			return entryPoints.values().iterator().next();
-		}
-		DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint(entryPoints);
-		entryPoint.setDefaultEntryPoint(entryPoints.values().iterator().next());
-		return entryPoint;
+		return this.defaultEntryPoint.build();
 	}
 
 	/**
@@ -340,128 +307,4 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 		return new HttpSessionRequestCache();
 	}
 
-	private static final class AuthenticationFactorDelegatingAuthenticationEntryPoint
-			implements AuthenticationEntryPoint {
-
-		private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer();
-
-		private final Map<String, AuthenticationEntryPoint> entryPoints;
-
-		private final AuthenticationEntryPoint defaults;
-
-		private AuthenticationFactorDelegatingAuthenticationEntryPoint(
-				Map<String, AuthenticationEntryPoint> entryPoints, AuthenticationEntryPoint defaults) {
-			this.entryPoints = new LinkedHashMap<>(entryPoints);
-			this.defaults = defaults;
-		}
-
-		@Override
-		public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex)
-				throws IOException, ServletException {
-			Collection<GrantedAuthority> authorization = authorizationRequest(ex);
-			entryPoint(authorization).commence(request, response, ex);
-		}
-
-		private AuthenticationEntryPoint entryPoint(Collection<GrantedAuthority> authorities) {
-			if (authorities == null) {
-				return this.defaults;
-			}
-			for (GrantedAuthority needed : authorities) {
-				AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
-				if (entryPoint != null) {
-					return entryPoint;
-				}
-			}
-			return this.defaults;
-		}
-
-		private Collection<GrantedAuthority> authorizationRequest(Exception ex) {
-			Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex);
-			AuthorizationDeniedException denied = (AuthorizationDeniedException) this.throwableAnalyzer
-				.getFirstThrowableOfType(AuthorizationDeniedException.class, chain);
-			if (denied == null) {
-				return List.of();
-			}
-			if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) {
-				return List.of();
-			}
-			return authorization.getAuthorities();
-		}
-
-	}
-
-	private static final class AuthenticationEntryPointAccessDeniedHandlerAdapter implements AccessDeniedHandler {
-
-		private final AuthenticationEntryPoint entryPoint;
-
-		private RequestCache requestCache = new NullRequestCache();
-
-		private AuthenticationEntryPointAccessDeniedHandlerAdapter(AuthenticationEntryPoint entryPoint) {
-			this.entryPoint = entryPoint;
-		}
-
-		void setRequestCache(RequestCache requestCache) {
-			Assert.notNull(requestCache, "requestCache cannot be null");
-			this.requestCache = requestCache;
-		}
-
-		@Override
-		public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied)
-				throws IOException, ServletException {
-			AuthenticationException ex = new InsufficientAuthenticationException("access denied", denied);
-			this.requestCache.saveRequest(request, response);
-			this.entryPoint.commence(request, response, ex);
-		}
-
-	}
-
-	private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler {
-
-		private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer();
-
-		private final Map<String, AccessDeniedHandler> deniedHandlers;
-
-		private final AccessDeniedHandler defaults;
-
-		private AuthenticationFactorDelegatingAccessDeniedHandler(Map<String, AccessDeniedHandler> deniedHandlers,
-				AccessDeniedHandler defaults) {
-			this.deniedHandlers = new LinkedHashMap<>(deniedHandlers);
-			this.defaults = defaults;
-		}
-
-		@Override
-		public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex)
-				throws IOException, ServletException {
-			Collection<GrantedAuthority> authorization = authorizationRequest(ex);
-			deniedHandler(authorization).handle(request, response, ex);
-		}
-
-		private AccessDeniedHandler deniedHandler(Collection<GrantedAuthority> authorities) {
-			if (authorities == null) {
-				return this.defaults;
-			}
-			for (GrantedAuthority needed : authorities) {
-				AccessDeniedHandler deniedHandler = this.deniedHandlers.get(needed.getAuthority());
-				if (deniedHandler != null) {
-					return deniedHandler;
-				}
-			}
-			return this.defaults;
-		}
-
-		private Collection<GrantedAuthority> authorizationRequest(Exception ex) {
-			Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex);
-			AuthorizationDeniedException denied = (AuthorizationDeniedException) this.throwableAnalyzer
-				.getFirstThrowableOfType(AuthorizationDeniedException.class, chain);
-			if (denied == null) {
-				return List.of();
-			}
-			if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) {
-				return List.of();
-			}
-			return authorization.getAuthorities();
-		}
-
-	}
-
 }

+ 4 - 1
config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java

@@ -233,7 +233,10 @@ public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
 		initDefaultLoginFilter(http);
 		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_PASSWORD");
+			AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint();
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher),
+					"FACTOR_PASSWORD");
 		}
 	}
 

+ 4 - 2
config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java

@@ -192,8 +192,10 @@ public final class HttpBasicConfigurer<B extends HttpSecurityBuilder<B>>
 		if (exceptionHandling == null) {
 			return;
 		}
-		exceptionHandling.defaultAuthenticationEntryPointFor(postProcess(this.authenticationEntryPoint),
-				preferredMatcher);
+		AuthenticationEntryPoint entryPoint = postProcess(this.authenticationEntryPoint);
+		exceptionHandling.defaultAuthenticationEntryPointFor(entryPoint, preferredMatcher);
+		exceptionHandling.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(entryPoint, preferredMatcher),
+				"FACTOR_PASSWORD");
 	}
 
 	private void registerDefaultLogoutSuccessHandler(B http, RequestMatcher preferredMatcher) {

+ 5 - 2
config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java

@@ -27,12 +27,14 @@ import org.springframework.http.converter.HttpMessageConverter;
 import org.springframework.security.authentication.ProviderManager;
 import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
 import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.access.intercept.AuthorizationFilter;
 import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
 import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
 import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
 import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
 import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
 import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
 import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
 import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
@@ -155,8 +157,9 @@ public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>
 	public void init(H http) throws Exception {
 		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"),
-					"FACTOR_WEBAUTHN");
+			AuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/login");
+			exceptions.defaultAuthenticationEntryPointFor(
+					(ep) -> ep.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE), "FACTOR_WEBAUTHN");
 		}
 	}
 

+ 3 - 2
config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java

@@ -184,8 +184,9 @@ public final class X509Configurer<H extends HttpSecurityBuilder<H>>
 			.setSharedObject(AuthenticationEntryPoint.class, new Http403ForbiddenEntryPoint());
 		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(new Http403ForbiddenEntryPoint(), AnyRequestMatcher.INSTANCE,
-					"FACTOR_X509");
+			AuthenticationEntryPoint forbidden = new Http403ForbiddenEntryPoint();
+			exceptions.defaultAuthenticationEntryPointFor(
+					(ep) -> ep.addEntryPointFor(forbidden, AnyRequestMatcher.INSTANCE), "FACTOR_X509");
 		}
 	}
 

+ 8 - 5
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java

@@ -373,10 +373,6 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 			http.authenticationProvider(new OidcAuthenticationRequestChecker());
 		}
 		this.initDefaultLoginFilter(http);
-		ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
-		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_AUTHORIZATION_CODE");
-		}
 	}
 
 	@Override
@@ -561,11 +557,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
 				new NegatedRequestMatcher(defaultLoginPageMatcher), formLoginNotEnabled);
 		// @formatter:off
-		return DelegatingAuthenticationEntryPoint.builder()
+		AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
 			.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
 			.defaultEntryPoint(getAuthenticationEntryPoint())
 			.build();
 		// @formatter:on
+		ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher),
+					"FACTOR_AUTHORIZATION_CODE");
+		}
+		return loginEntryPoint;
 	}
 
 	private RequestMatcher getFormLoginNotEnabledRequestMatcher(B http) {

+ 2 - 4
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java

@@ -259,10 +259,6 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 		if (authenticationProvider != null) {
 			http.authenticationProvider(authenticationProvider);
 		}
-		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
-		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, "FACTOR_BEARER");
-		}
 	}
 
 	@Override
@@ -331,6 +327,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 			RequestMatcher preferredMatcher = new OrRequestMatcher(
 					Arrays.asList(this.requestMatcher, X_REQUESTED_WITH, restNotHtmlMatcher, allMatcher));
 			exceptionHandling.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, preferredMatcher);
+			exceptionHandling.defaultAuthenticationEntryPointFor(
+					(ep) -> ep.addEntryPointFor(this.authenticationEntryPoint, preferredMatcher), "FACTOR_BEARER");
 		}
 	}
 

+ 4 - 62
config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java

@@ -16,15 +16,10 @@
 
 package org.springframework.security.config.annotation.web.configurers.ott;
 
-import java.io.IOException;
 import java.util.Collections;
 import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
 
-import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
 
 import org.springframework.context.ApplicationContext;
 import org.springframework.http.HttpMethod;
@@ -42,13 +37,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractAu
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
 import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
 import org.springframework.security.core.Authentication;
-import org.springframework.security.core.AuthenticationException;
-import org.springframework.security.core.context.SecurityContextHolder;
-import org.springframework.security.core.context.SecurityContextHolderStrategy;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.web.AuthenticationEntryPoint;
-import org.springframework.security.web.FormPostRedirectStrategy;
-import org.springframework.security.web.RedirectStrategy;
 import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@@ -67,7 +57,6 @@ import org.springframework.security.web.csrf.CsrfToken;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
-import org.springframework.web.util.UriComponentsBuilder;
 
 /**
  * An {@link AbstractHttpConfigurer} for One-Time Token Login.
@@ -149,9 +138,10 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 		intiDefaultLoginFilter(http);
 		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptions != null) {
-			AuthenticationEntryPoint entryPoint = new PostAuthenticationEntryPoint(
-					this.tokenGeneratingUrl + "?username={u}", Map.of("u", Authentication::getName));
-			exceptions.defaultAuthenticationEntryPointFor(entryPoint, "FACTOR_OTT");
+			AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint();
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher),
+					"FACTOR_OTT");
 		}
 	}
 
@@ -410,52 +400,4 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 		return this.context;
 	}
 
-	private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint {
-
-		private final String entryPointUri;
-
-		private final Map<String, Function<Authentication, String>> params;
-
-		private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
-			.getContextHolderStrategy();
-
-		private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy();
-
-		private PostAuthenticationEntryPoint(String entryPointUri,
-				Map<String, Function<Authentication, String>> params) {
-			this.entryPointUri = entryPointUri;
-			this.params = params;
-		}
-
-		@Override
-		public void commence(HttpServletRequest request, HttpServletResponse response,
-				AuthenticationException authException) throws IOException, ServletException {
-			Authentication authentication = getAuthentication(authException);
-			Assert.notNull(authentication, "could not find authentication in order to perform post");
-			Map<String, String> params = this.params.entrySet()
-				.stream()
-				.collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication)));
-			UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri);
-			CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
-			if (csrf != null) {
-				builder.queryParam(csrf.getParameterName(), csrf.getToken());
-			}
-			String entryPointUrl = builder.build(false).expand(params).toUriString();
-			this.redirectStrategy.sendRedirect(request, response, entryPointUrl);
-		}
-
-		private Authentication getAuthentication(AuthenticationException authException) {
-			Authentication authentication = authException.getAuthenticationRequest();
-			if (authentication != null && authentication.isAuthenticated()) {
-				return authentication;
-			}
-			authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
-			if (authentication != null && authentication.isAuthenticated()) {
-				return authentication;
-			}
-			return null;
-		}
-
-	}
-
 }

+ 8 - 5
config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java

@@ -305,10 +305,6 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		if (this.authenticationManager == null) {
 			registerDefaultAuthenticationProvider(http);
 		}
-		ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
-		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_SAML_RESPONSE");
-		}
 	}
 
 	/**
@@ -348,11 +344,18 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
 				new NegatedRequestMatcher(defaultLoginPageMatcher));
 		// @formatter:off
-		return DelegatingAuthenticationEntryPoint.builder()
+		AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
 				.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
 				.defaultEntryPoint(getAuthenticationEntryPoint())
 				.build();
 		// @formatter:on
+		ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher),
+					"FACTOR_SAML_RESPONSE");
+		}
+		return loginEntryPoint;
 	}
 
 	private void setAuthenticationRequestRepository(B http,

+ 4 - 6
config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java

@@ -64,7 +64,6 @@ import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.servlet.config.annotation.EnableWebMvc;
 
-import static org.hamcrest.Matchers.containsString;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.atLeastOnce;
@@ -79,7 +78,6 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock
 import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -423,8 +421,8 @@ public class FormLoginConfigurerTests {
 			.andExpect(redirectedUrl("http://localhost/login"));
 		user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build();
 		this.mockMvc.perform(get("/profile").with(user(user)))
-			.andExpect(status().isOk())
-			.andExpect(content().string(containsString("/ott/generate")));
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login"));
 		user = PasswordEncodedUser.withUserDetails(user)
 			.authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
 			.build();
@@ -433,8 +431,8 @@ public class FormLoginConfigurerTests {
 
 	@Test
 	void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception {
-		this.spring.register(MfaDslX509Config.class, UserConfig.class, org.springframework.security.config.annotation.web.configurers.FormLoginConfigurerTests.BasicMfaController.class).autowire();
-		this.mockMvc.perform(get("/profile")).andExpect(status().isForbidden());
+		this.spring.register(MfaDslX509Config.class, UserConfig.class, BasicMfaController.class).autowire();
+		this.mockMvc.perform(get("/profile")).andExpect(status().is3xxRedirection());
 		this.mockMvc.perform(get("/profile").with(user(User.withUsername("rod").authorities("profile:read").build())))
 			.andExpect(status().isForbidden());
 		this.mockMvc.perform(get("/login")).andExpect(status().isOk());

+ 223 - 0
web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java

@@ -0,0 +1,223 @@
+/*
+ * Copyright 2004-present 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.web.access;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.InsufficientAuthenticationException;
+import org.springframework.security.authorization.AuthorityAuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
+import org.springframework.security.web.savedrequest.NullRequestCache;
+import org.springframework.security.web.savedrequest.RequestCache;
+import org.springframework.security.web.util.ThrowableAnalyzer;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
+
+/**
+ * An {@link AccessDeniedHandler} that adapts {@link AuthenticationEntryPoint}s based on
+ * missing {@link GrantedAuthority}s. These authorities are specified in an
+ * {@link AuthorityAuthorizationDecision} inside an {@link AuthorizationDeniedException}.
+ *
+ * <p>
+ * This is helpful in adaptive authentication scenarios where an
+ * {@link org.springframework.security.authorization.AuthorizationManager} indicates
+ * additional authorities needed to access a given resource.
+ * </p>
+ *
+ * <p>
+ * For example, if an
+ * {@link org.springframework.security.authorization.AuthorizationManager} states that to
+ * access the home page, the user needs the {@code FACTOR_OTT} authority, then this
+ * handler can be configured in the following way to redirect to the one-time-token login
+ * page:
+ * </p>
+ *
+ * <code>
+ *     AccessDeniedHandler handler = DelegatingMissingAuthorityAccessDeniedHandler.builder()
+ *         .authorities("FACTOR_OTT").commence(new LoginUrlAuthenticationEntryPoint("/login"))
+ *         .authorities("FACTOR_PASSWORD")...
+ *         .build();
+ * </code>
+ *
+ * @author Josh Cummings
+ * @since 7.0
+ * @see AuthorizationDeniedException
+ * @see AuthorityAuthorizationDecision
+ * @see org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
+ */
+public final class DelegatingMissingAuthorityAccessDeniedHandler implements AccessDeniedHandler {
+
+	private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer();
+
+	private final Map<String, AuthenticationEntryPoint> entryPoints;
+
+	private RequestCache requestCache = new NullRequestCache();
+
+	private AccessDeniedHandler defaultAccessDeniedHandler = new AccessDeniedHandlerImpl();
+
+	private DelegatingMissingAuthorityAccessDeniedHandler(Map<String, AuthenticationEntryPoint> entryPoints) {
+		this.entryPoints = entryPoints;
+	}
+
+	@Override
+	public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied)
+			throws IOException, ServletException {
+		Collection<GrantedAuthority> authorities = missingAuthorities(denied);
+		AuthenticationEntryPoint entryPoint = entryPoint(authorities);
+		if (entryPoint == null) {
+			this.defaultAccessDeniedHandler.handle(request, response, denied);
+			return;
+		}
+		this.requestCache.saveRequest(request, response);
+		AuthenticationException ex = new InsufficientAuthenticationException("missing authorities", denied);
+		entryPoint.commence(request, response, ex);
+	}
+
+	/**
+	 * Use this {@link AccessDeniedHandler} for {@link AccessDeniedException}s that this
+	 * handler doesn't support. By default, this uses {@link AccessDeniedHandlerImpl}.
+	 * @param defaultAccessDeniedHandler the default {@link AccessDeniedHandler} to use
+	 */
+	public void setDefaultAccessDeniedHandler(AccessDeniedHandler defaultAccessDeniedHandler) {
+		this.defaultAccessDeniedHandler = defaultAccessDeniedHandler;
+	}
+
+	/**
+	 * Use this {@link RequestCache} to remember the current request.
+	 * <p>
+	 * Uses {@link NullRequestCache} by default
+	 * </p>
+	 * @param requestCache the {@link RequestCache} to use
+	 */
+	public void setRequestCache(RequestCache requestCache) {
+		this.requestCache = requestCache;
+	}
+
+	private @Nullable AuthenticationEntryPoint entryPoint(Collection<GrantedAuthority> authorities) {
+		for (GrantedAuthority needed : authorities) {
+			AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
+			if (entryPoint == null) {
+				continue;
+			}
+			return entryPoint;
+		}
+		return null;
+	}
+
+	private Collection<GrantedAuthority> missingAuthorities(AccessDeniedException ex) {
+		AuthorizationDeniedException denied = findAuthorizationDeniedException(ex);
+		if (denied == null) {
+			return List.of();
+		}
+		if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) {
+			return List.of();
+		}
+		return authorization.getAuthorities();
+	}
+
+	private @Nullable AuthorizationDeniedException findAuthorizationDeniedException(AccessDeniedException ex) {
+		if (ex instanceof AuthorizationDeniedException denied) {
+			return denied;
+		}
+		Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex);
+		return (AuthorizationDeniedException) this.throwableAnalyzer
+			.getFirstThrowableOfType(AuthorizationDeniedException.class, chain);
+	}
+
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * A builder for configuring the set of authority/entry-point pairs
+	 *
+	 * @author Josh Cummings
+	 * @since 7.0
+	 */
+	public static final class Builder {
+
+		private final Map<String, DelegatingAuthenticationEntryPoint.Builder> entryPointByRequestMatcherByAuthority = new LinkedHashMap<>();
+
+		private Builder() {
+
+		}
+
+		DelegatingAuthenticationEntryPoint.Builder entryPointBuilder(String authority) {
+			return this.entryPointByRequestMatcherByAuthority.computeIfAbsent(authority,
+					(k) -> DelegatingAuthenticationEntryPoint.builder());
+		}
+
+		void entryPoint(String authority, AuthenticationEntryPoint entryPoint) {
+			DelegatingAuthenticationEntryPoint.Builder builder = DelegatingAuthenticationEntryPoint.builder()
+				.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE);
+			this.entryPointByRequestMatcherByAuthority.put(authority, builder);
+		}
+
+		/**
+		 * Bind these authorities to the given {@link AuthenticationEntryPoint}
+		 * @param entryPoint the {@link AuthenticationEntryPoint} for the given
+		 * authorities
+		 * @param authorities the authorities
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder addEntryPointFor(AuthenticationEntryPoint entryPoint, String... authorities) {
+			for (String authority : authorities) {
+				Builder.this.entryPoint(authority, entryPoint);
+			}
+			return this;
+		}
+
+		/**
+		 * Bind these authorities to the given {@link AuthenticationEntryPoint}
+		 * @param entryPoint a consumer to configure the underlying
+		 * {@link DelegatingAuthenticationEntryPoint}
+		 * @param authorities the authorities
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder addEntryPointFor(Consumer<DelegatingAuthenticationEntryPoint.Builder> entryPoint,
+				String... authorities) {
+			for (String authority : authorities) {
+				entryPoint.accept(Builder.this.entryPointBuilder(authority));
+			}
+			return this;
+		}
+
+		public DelegatingMissingAuthorityAccessDeniedHandler build() {
+			Map<String, AuthenticationEntryPoint> entryPointByAuthority = new LinkedHashMap<>();
+			for (String authority : this.entryPointByRequestMatcherByAuthority.keySet()) {
+				entryPointByAuthority.put(authority, this.entryPointByRequestMatcherByAuthority.get(authority).build());
+			}
+			return new DelegatingMissingAuthorityAccessDeniedHandler(entryPointByAuthority);
+		}
+
+	}
+
+}

+ 145 - 0
web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java

@@ -0,0 +1,145 @@
+/*
+ * Copyright 2004-present 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.web.access;
+
+import java.util.Collection;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authorization.AuthorityAuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.savedrequest.RequestCache;
+import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class DelegatingMissingAuthorityAccessDeniedHandlerTests {
+
+	DelegatingMissingAuthorityAccessDeniedHandler.Builder builder;
+
+	MockHttpServletRequest request;
+
+	MockHttpServletResponse response;
+
+	@Mock
+	AuthenticationEntryPoint factorEntryPoint;
+
+	@Mock
+	AccessDeniedHandler defaultAccessDeniedHandler;
+
+	@BeforeEach
+	void setUp() {
+		this.builder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
+		this.builder.addEntryPointFor(this.factorEntryPoint, "FACTOR");
+		this.request = new MockHttpServletRequest();
+		this.response = new MockHttpServletResponse();
+	}
+
+	@Test
+	void whenKnownAuthorityThenCommences() throws Exception {
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR"));
+		verify(this.factorEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenUnknownAuthorityThenDefaultCommences() throws Exception {
+		DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler);
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("ROLE_USER"));
+		verify(this.defaultAccessDeniedHandler).handle(any(), any(), any());
+		verifyNoInteractions(this.factorEntryPoint);
+	}
+
+	@Test
+	void whenNoAuthoritiesFoundThenDefaultCommences() throws Exception {
+		DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler);
+		accessDeniedHandler.handle(this.request, this.response, new AccessDeniedException("access denied"));
+		verify(this.defaultAccessDeniedHandler).handle(any(), any(), any());
+	}
+
+	@Test
+	void whenMultipleAuthoritiesThenFirstMatchCommences() throws Exception {
+		AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class);
+		this.builder.addEntryPointFor(passwordEntryPoint, "PASSWORD");
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD", "FACTOR"));
+		verify(passwordEntryPoint).commence(any(), any(), any());
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR", "PASSWORD"));
+		verify(this.factorEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenCustomRequestCacheThenUses() throws Exception {
+		RequestCache requestCache = mock(RequestCache.class);
+		DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.setRequestCache(requestCache);
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR"));
+		verify(requestCache).saveRequest(any(), any());
+		verify(this.factorEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenKnownAuthorityButNoRequestMatchThenCommences() throws Exception {
+		AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class);
+		RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With");
+		this.builder.addEntryPointFor((ep) -> ep.addEntryPointFor(passwordEntryPoint, xhr), "PASSWORD");
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD"));
+		verify(passwordEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenMultipleEntryPointsThenFirstRequestMatchCommences() throws Exception {
+		AuthenticationEntryPoint basicPasswordEntryPoint = mock(AuthenticationEntryPoint.class);
+		AuthenticationEntryPoint formPasswordEntryPoint = mock(AuthenticationEntryPoint.class);
+		RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With");
+		this.builder.addEntryPointFor(
+				(ep) -> ep.addEntryPointFor(basicPasswordEntryPoint, xhr).defaultEntryPoint(formPasswordEntryPoint),
+				"PASSWORD");
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD"));
+		verify(formPasswordEntryPoint).commence(any(), any(), any());
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("X-Requested-With", "XmlHttpRequest");
+		accessDeniedHandler.handle(request, this.response, missingAuthorities("PASSWORD"));
+		verify(basicPasswordEntryPoint).commence(any(), any(), any());
+	}
+
+	AuthorizationDeniedException missingAuthorities(String... authorities) {
+		Collection<GrantedAuthority> granted = AuthorityUtils.createAuthorityList(authorities);
+		AuthorityAuthorizationDecision decision = new AuthorityAuthorizationDecision(false, granted);
+		return new AuthorizationDeniedException("access denied", decision);
+	}
+
+}