SecurityConfig.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * 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
 *
 *     http://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.entrystore.rest.springboot.security;

import com.github.benmanes.caffeine.cache.Ticker;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apereo.cas.client.validation.TicketValidator;
import org.entrystore.repository.config.Settings;
import org.entrystore.repository.security.Password;
import org.entrystore.rest.springboot.configuration.CasCustomConfiguration;
import org.entrystore.rest.springboot.configuration.CorsProperties;
import org.entrystore.rest.springboot.configuration.HttpBasicAuthConfiguration;
import org.entrystore.rest.springboot.configuration.IndexedListSettings;
import org.entrystore.rest.springboot.configuration.OidcCustomConfiguration;
import org.entrystore.rest.springboot.configuration.PasswordLoginMode;
import org.entrystore.rest.springboot.configuration.SamlCustomConfiguration;
import org.entrystore.rest.springboot.filter.CheckUsernamePasswordFilter;
import org.entrystore.rest.springboot.filter.CsrfCookieFilter;
import org.entrystore.rest.springboot.filter.IgnoreAuthFilter;
import org.entrystore.rest.springboot.filter.ReloadUserPropertiesFilter;
import org.entrystore.rest.springboot.filter.SetUserURIAfterAuthenticationFilter;
import org.entrystore.rest.springboot.model.api.ErrorResponse;
import org.entrystore.rest.springboot.model.auth.UserAuthRole;
import org.entrystore.rest.springboot.service.OidcAuthService;
import org.entrystore.rest.springboot.service.SamlAuthService;
import org.entrystore.rest.springboot.service.auth.OidcAuthStateCache;
import org.entrystore.rest.springboot.service.auth.OidcLoginRateLimiter;
import org.entrystore.rest.springboot.service.auth.SamlLoginRateLimiter;
import org.entrystore.rest.springboot.util.ErrorResponseWriter;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.web.server.Cookie;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.cas.web.CasAuthenticationFilter;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver;
import org.springframework.security.saml2.provider.service.web.OpenSaml5AuthenticationTokenConverter;
import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.context.DelegatingSecurityContextRepository;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.web.servlet.HandlerExceptionResolver;

import java.time.Duration;
import java.util.Objects;
import java.util.Optional;

@Slf4j
@EnableMethodSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

	/**
	 * Requests that need no existing session, so a stale session cookie on them is expired and the request served as
	 * guest even with {@code entrystore.auth.cookie.invalid-token-error} on: the login pages and SSO initiations,
	 * which a user whose login ended is sent to, the sign-up and password-reset flows, whose emailed links are often
	 * opened in a browser holding an old login, and {@code ignoreAuth}, as in 5.x.
	 */
	private static final RequestMatcher SESSIONLESS_REQUESTS = new OrRequestMatcher(
			PathPatternRequestMatcher.withDefaults().matcher("/auth/login"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/saml"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/oidc"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/cas"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/signup"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/signup/confirm"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/pwreset"),
			PathPatternRequestMatcher.withDefaults().matcher("/auth/pwreset/confirm"),
			IgnoreAuthFilter::isRequested);

	private final CheckUsernamePasswordFilter checkUsernamePasswordFilter;
	private final IgnoreAuthFilter ignoreAuthFilter;
	private final SetUserURIAfterAuthenticationFilter setUserURIAfterAuthenticationFilter;
	private final ReloadUserPropertiesFilter reloadUserPropertiesFilter;
	private final HandlerExceptionResolver handlerExceptionResolver;
	private final FormLoginAuthenticationFailureHandler formLoginAuthenticationFailureHandler;
	private final FormLoginAuthenticationSuccessHandler formLoginAuthenticationSuccessHandler;

	private final CorsProperties corsProperties;
	private final ErrorResponseWriter errorResponseWriter;

	private final CsrfRequestMatcher csrfRequestMatcher;
	private final CsrfCookieFilter csrfCookieFilter;

	private final AuthTokenCookies authTokenCookies;

	// SAML-auth related beans (handlers and limiter optional — only present when entrystore.auth.saml.enabled=true)
	private final SamlCustomConfiguration samlConfiguration;
	private final SamlAuthService samlAuthService;
	private final Optional<SamlLoginSuccessHandler> samlLoginSuccessHandler;
	private final Optional<SamlLoginFailureHandler> samlLoginFailureHandler;
	private final Optional<SamlLoginRateLimiter> samlLoginRateLimiter;
	private final Optional<RelyingPartyRegistrationRepository> repo; // optional as it will be injected only when Spring's SAML properties are configured
	private final SamlRelayStateResolver samlRelayStateResolver;
	private final CacheSaml2AuthenticationRequestRepository saml2AuthenticationRequestRepository;

	// CAS-auth related beans (optional — only present when entrystore.auth.cas.enabled=true)
	private final CasCustomConfiguration casConfiguration;
	private final Optional<ServiceProperties> casServiceProperties;
	private final Optional<TicketValidator> casTicketValidator;
	private final Optional<CasLoginSuccessHandler> casLoginSuccessHandler;

	// OIDC-auth related beans (success handler and limiter optional — only present when entrystore.auth.oidc.enabled=true)
	private final OidcCustomConfiguration oidcConfiguration;
	private final Optional<OidcLoginSuccessHandler> oidcLoginSuccessHandler;
	private final Optional<OidcLoginRateLimiter> oidcLoginRateLimiter;
	private final Optional<ClientRegistrationRepository> clientRegistrationRepository; // optional as it will be injected only when Spring's OAuth2 client registrations are configured
	private final OidcAuthService oidcAuthService;
	private final OidcAuthStateCache oidcAuthStateCache;
	private final CacheOAuth2AuthorizationRequestRepository oauth2AuthorizationRequestRepository;

	private final HttpBasicAuthConfiguration httpBasicConfig;
	private final PasswordLoginMode passwordLoginMode;

	private final Environment environment;

	@Value("${server.servlet.session.cookie.secure:true}")
	private boolean sessionCookieSecure;

	@Value("${entrystore.csrf.cookie-name:XSRF-TOKEN}")
	private String csrfCookieName;

	// Default is false until the common EntryStore clients echo the XSRF-TOKEN cookie as an
	// X-XSRF-TOKEN header on mutations — see ENTRYSTORE-1008 for the compatibility discussion.
	@Value("${entrystore.csrf.enabled:false}")
	private boolean csrfEnabled;

	// When false, an unknown or expired session cookie is expired and the request is served as guest instead of 401
	@Value("${entrystore.auth.cookie.invalid-token-error:true}")
	private boolean invalidTokenError;

	private Cookie.SameSite sessionCookieSameSite;

	@PostConstruct
	public void init() {
		sessionCookieSameSite = resolveSessionCookieSameSite(environment);
	}

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry,
												   AuthenticationEntryPoint customEntryPoint,
												   AccessDeniedHandler customAccessDeniedHandler) throws Exception {

		if (corsProperties.enabled()) {
			http.cors(Customizer.withDefaults());
		} else {
			http.cors(AbstractHttpConfigurer::disable);
		}

		if (csrfEnabled) {
			http
					.csrf(csrf -> csrf
							.csrfTokenRepository(csrfTokenRepository())
							.csrfTokenRequestHandler(new HeaderOnlyForMultipartCsrfTokenRequestHandler())
							.requireCsrfProtectionMatcher(csrfRequestMatcher))
					.addFilterAfter(csrfCookieFilter, CsrfFilter.class);
		} else {
			// Migration escape hatch for clients that do not yet echo the XSRF-TOKEN cookie as
			// X-XSRF-TOKEN. CsrfCookieFilter is deliberately not registered either, so no
			// XSRF-TOKEN cookie is issued while enforcement is off.
			log.warn("CSRF protection is DISABLED (entrystore.csrf.enabled=false). Cookie-authenticated "
					+ "sessions are exposed to cross-site request forgery; use only as a temporary measure "
					+ "until all clients send the X-XSRF-TOKEN header.");
			http.csrf(AbstractHttpConfigurer::disable);
		}

		var entryPoint = isHttpBasicEnabled() ? authChallengeAwareEntryPoint(customEntryPoint) : customEntryPoint;

		http
				// Disable Spring Security's default CacheControlHeadersWriter so that CacheControlFilter
				// governs the Cache-Control / Pragma / Expires headers end-to-end. The default writer would
				// otherwise stamp "no-cache, no-store, max-age=0, must-revalidate" on every response,
				// including permit-all public endpoints — which contradicts the per-request policy this app
				// needs (private,no-cache for authenticated; no header for anonymous so static and
				// controller-set values can pass through unchanged).
				.headers(headers -> headers.cacheControl(HeadersConfigurer.CacheControlConfig::disable))
				// Set explicitly so SessionManagementFilter also sees the request-scoped HTTP Basic context and
				// starts no session (and auth_token cookie) for it, as in 5.x, nor for a session that a concurrent
				// request ended; see SessionContextRepository
				.securityContext(context -> context.securityContextRepository(sessionAndRequestContextRepository()))
				// Nothing resumes a request after login (the entry point answers 401), and saving one would start a
				// session, and thus an auth_token cookie, for a guest's browser request to a protected page
				.requestCache(RequestCacheConfigurer::disable)
				.sessionManagement(session -> {
					// ConcurrentSessionFilter runs the logout handlers, and thereby expires the cookie, before this strategy
					session.sessionConcurrency(concurrency -> concurrency
							.maximumSessions(-1)
							.sessionRegistry(sessionRegistry)
							.expiredSessionStrategy(event -> {
								if (!invalidTokenError || SESSIONLESS_REQUESTS.matches(event.getRequest())) {
									event.getFilterChain().doFilter(event.getRequest(), event.getResponse());
									return;
								}
								errorResponseWriter.writeErrorResponseAsJson(event.getResponse(), ErrorResponse.builder()
										.status(HttpStatus.UNAUTHORIZED.value())
										.path(event.getRequest().getRequestURI())
										.error("Session expired")
										.build());
							}));
					if (invalidTokenError) {
						session.invalidSessionStrategy((request, response) -> {
							authTokenCookies.expireAll(request, response);
							errorResponseWriter.writeErrorResponseAsJson(response, ErrorResponse.builder()
									.status(HttpStatus.UNAUTHORIZED.value())
									.path(request.getRequestURI())
									.error("Session expired or invalid")
									.build());
						});
					}
				})
				.authorizeHttpRequests(auth -> auth
						.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole(UserAuthRole.ADMIN.name())
						.requestMatchers("/management/status/extended").hasRole(UserAuthRole.ADMIN.name())
						.requestMatchers(HttpMethod.POST, "/*/import").hasRole(UserAuthRole.ADMIN.name())
						.requestMatchers("/auth/tokens").hasAnyRole(UserAuthRole.USER.name(), UserAuthRole.ADMIN.name())
						.anyRequest().permitAll()
				)
				.logout(logout -> logout
						// Pin logout to POST so a same-site `<a href="/auth/logout">` or `<img src=…>`
						// from a relaxed-SameSite cookie context cannot force-log-out the user.
						// CsrfRequestMatcher then requires a valid X-XSRF-TOKEN on the POST.
						.logoutRequestMatcher(PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, "/auth/logout"))
						.addLogoutHandler(authTokenCookies)
						.logoutSuccessHandler((_, response, _) ->
								response.setStatus(HttpStatus.NO_CONTENT.value())
						)
						.permitAll())
				.addFilterAfter(setUserURIAfterAuthenticationFilter, AnonymousAuthenticationFilter.class)
				.addFilterBefore(ignoreAuthFilter, SetUserURIAfterAuthenticationFilter.class)
				.addFilterAfter(reloadUserPropertiesFilter, SetUserURIAfterAuthenticationFilter.class)
				// below disables the auto redirect to login page when user is not authenticated, instead reply with 401
				.exceptionHandling(e -> e
						.authenticationEntryPoint(entryPoint)
						.accessDeniedHandler(customAccessDeniedHandler)
				);

		http.addFilterBefore(new InvalidSessionCookieFilter(authTokenCookies,
				invalidTokenError ? SESSIONLESS_REQUESTS : AnyRequestMatcher.INSTANCE), SessionManagementFilter.class);
		if (!authTokenCookies.isRefreshExpirationOnAccess()) {
			http.addFilterBefore(new SessionLifetimeFilter(authTokenCookies), SecurityContextHolderFilter.class);
		}

		if (passwordLoginMode == PasswordLoginMode.OFF) {
			// DisabledRouteFilter answers /auth/cookie and /auth/login with 404 before this chain runs
			log.info("Password login disabled");
		} else {
			http
					.formLogin(login -> login
							.loginPage("/auth/login")
							.loginProcessingUrl("/auth/cookie")
							.successHandler(formLoginAuthenticationSuccessHandler)
							.failureHandler(formLoginAuthenticationFailureHandler)
							.usernameParameter("auth_username")
							.passwordParameter("auth_password")
							.permitAll()
					)
					.addFilterBefore(checkUsernamePasswordFilter, UsernamePasswordAuthenticationFilter.class);
		}

		if (isHttpBasicEnabled()) {
			log.info("Basic Auth Enabled (credential cache TTL={}, max entries={})",
					httpBasicConfig.cache().ttl(), httpBasicConfig.cache().maxSize());
			http.httpBasic(basic -> {
				basic.authenticationEntryPoint(entryPoint);
				if (passwordLoginMode == PasswordLoginMode.WHITELIST) {
					var whitelist = IndexedListSettings.read(environment, Settings.AUTH_PASSWORD_WHITELIST);
					// An anonymous class, not a lambda: see the SAML branch below.
					basic.withObjectPostProcessor(new ObjectPostProcessor<BasicAuthenticationFilter>() {
						@Override
						public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
							filter.setAuthenticationConverter(new WhitelistBasicAuthenticationConverter(whitelist));
							return filter;
						}
					});
				}
			});
		} else if (httpBasicConfig.enabled()) {
			log.warn("Basic Auth Disabled: entrystore.auth.password=off overrides "
					+ "entrystore.auth.http-basic.enabled=true");
		} else {
			log.info("Basic Auth Disabled");
		}

		var cacheAwareRedirectStrategy = new CacheAwareRedirectStrategy();

		if (samlConfiguration.enabled()) {
			log.info("SAML Auth Enabled");
			samlConfiguration.idp().forEach((id, idp) ->
					log.info("SAML IdP \"{}\" - Domains: {}, Auto Provisioning: {}", id, idp.domains(), idp.userAutoProvisioning()));
			log.info("SAML Default IdP: {}", samlConfiguration.defaultIdp());
			samlConfiguration.redirectDomainWhitelist().forEach(domain -> log.info("Allowed domain for redirects: {}", domain));

			var samlHandler = samlLoginSuccessHandler.orElseThrow(() -> new IllegalStateException(
					"SAML is enabled but SamlLoginSuccessHandler bean is missing — check the " +
							"entrystore.auth.saml.enabled binding."));
			// Custom success URLs flow through the validated relay-state cache (SamlRelayStateResolver);
			// the handler must NOT read a request parameter for the target (open-redirect, ENTRYSTORE-996),
			// so only the trusted default target is configured here.
			samlHandler.setDefaultTargetUrl(samlConfiguration.redirectSuccess().url());
			// Stamp Cache-Control: private, no-store on the 302 carrying the session Set-Cookie
			// before sendRedirect commits the response — CacheControlFilter's post-chain check
			// cannot run after a committed response, so the redirect strategy closes that gap.
			samlHandler.setRedirectStrategy(cacheAwareRedirectStrategy);
			var samlFailureHandler = samlLoginFailureHandler.orElseThrow(() -> new IllegalStateException(
					"SAML is enabled but SamlLoginFailureHandler bean is missing — check the " +
							"entrystore.auth.saml.enabled binding."));

			// Also processes SAML responses posted to the 5.x assertion consumer service (POST /auth/saml?idp=<id>).
			var acsMatcher = new SamlAcsRequestMatcher();
			http.saml2Login(samlLogin -> samlLogin
					.loginPage("/auth/saml")
					.failureHandler(samlFailureHandler)
					.authenticationRequestResolver(createCustomResolver())
					.authenticationConverter(createAcsTokenConverter(acsMatcher))
					.successHandler(samlHandler)
					// An anonymous class, not a lambda: the configurer applies a post-processor whose type
					// argument it cannot resolve to every object it builds.
					.withObjectPostProcessor(new ObjectPostProcessor<Saml2WebSsoAuthenticationFilter>() {
						@Override
						public <O extends Saml2WebSsoAuthenticationFilter> O postProcess(O filter) {
							filter.setRequiresAuthenticationRequestMatcher(acsMatcher);
							return filter;
						}
					}));
			var rateLimiter = samlLoginRateLimiter.orElseThrow(() -> new IllegalStateException(
					"SAML is enabled but SamlLoginRateLimiter bean is missing — check the " +
							"entrystore.auth.saml.enabled binding."));
			http.addFilterBefore(SsoLoginRateLimitFilter.forSaml(rateLimiter, samlAuthService),
					Saml2WebSsoAuthenticationRequestFilter.class);
		} else {
			log.info("SAML Auth Disabled");
		}

		if (casConfiguration.enabled()) {
			log.info("CAS Auth Enabled");

			var casFilter = new CasAuthenticationFilter();
			// CSRF is disabled globally and getParameter() reads form bodies, so pinning to GET
			// prevents a cross-site POST from submitting a stolen ticket.
			RequestMatcher ticketRequired = request -> request.getParameter("ticket") != null;
			casFilter.setRequiresAuthenticationRequestMatcher(new AndRequestMatcher(
					PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.GET, "/auth/cas"),
					ticketRequired));
			casFilter.setAuthenticationManager(new ProviderManager(CasConfig.casAuthenticationProvider(
					casServiceProperties.orElseThrow(() -> new IllegalStateException(
							"CAS is enabled but the ServiceProperties bean is missing — check CasConfig.")),
					casTicketValidator.orElseThrow(() -> new IllegalStateException(
							"CAS is enabled but the TicketValidator bean is missing — check CasConfig.")))));

			var handler = casLoginSuccessHandler.orElseThrow(() -> new IllegalStateException(
					"CAS is enabled but CasLoginSuccessHandler bean is missing — check CasConfig."));
			handler.setDefaultTargetUrl(casConfiguration.redirectSuccess().url());
			// See the SAML branch above for the rationale.
			handler.setRedirectStrategy(cacheAwareRedirectStrategy);
			casFilter.setAuthenticationSuccessHandler(handler);
			casFilter.setAuthenticationFailureHandler(
					new SsoLoginFailureHandler("CAS", casConfiguration.redirectFailure().url()));

			http.addFilterBefore(casFilter, UsernamePasswordAuthenticationFilter.class);
			// The session strategy and context repository the form, SAML and OIDC logins get, so a CAS login changes
			// the session id and registers the session; set in configure, as the strategy is shared only from init on
			http.with(new SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>() {
				@Override
				public void configure(HttpSecurity builder) {
					casFilter.setSessionAuthenticationStrategy(Objects.requireNonNull(
							builder.getSharedObject(SessionAuthenticationStrategy.class)));
					casFilter.setSecurityContextRepository(builder.getSharedObject(SecurityContextRepository.class));
				}
			});
		} else {
			log.info("CAS Auth Disabled");
		}

		if (oidcConfiguration.enabled()) {
			log.info("OIDC Auth Enabled");
			oidcConfiguration.provider().forEach((id, provider) ->
					log.info("OIDC provider \"{}\" - Domains: {}, Auto Provisioning: {}, Username Claim: {}",
							id, provider.domains(), provider.userAutoProvisioning(), provider.usernameClaim()));
			log.info("OIDC Default Provider: {}", oidcConfiguration.defaultProvider());
			oidcConfiguration.redirectDomainWhitelist().forEach(domain -> log.info("Allowed domain for redirects: {}", domain));

			var oidcHandler = oidcLoginSuccessHandler.orElseThrow(() -> new IllegalStateException(
					"OIDC is enabled but OidcLoginSuccessHandler bean is missing — check the " +
							"entrystore.auth.oidc.enabled binding."));
			// Custom success URLs flow through the validated state-keyed cache (OidcAuthorizationRequestResolver);
			// the handler must NOT read a request parameter for the target — see the SAML branch above for the
			// ENTRYSTORE-996 open-redirect rationale. Only the trusted default target is configured here.
			oidcHandler.setDefaultTargetUrl(oidcConfiguration.redirectSuccess().url());
			// See the SAML branch above for the Cache-Control rationale.
			oidcHandler.setRedirectStrategy(cacheAwareRedirectStrategy);

			var registrations = clientRegistrationRepository.orElseThrow(() -> new IllegalStateException(
					"OIDC is enabled but no ClientRegistrationRepository is available — configure at least " +
							"one client under spring.security.oauth2.client.registration.*"));

			http.oauth2Login(oidcLogin -> oidcLogin
					.loginPage("/auth/oidc")
					.authorizationEndpoint(authorization -> authorization
							.authorizationRequestResolver(new OidcAuthorizationRequestResolver(
									registrations, oidcAuthService, oidcAuthStateCache))
							// Keyed by the state parameter instead of the HTTP session so the flow survives
							// SameSite=Strict session cookies on the cross-site callback redirect.
							.authorizationRequestRepository(oauth2AuthorizationRequestRepository))
					// Names the principal after the per-provider username claim (default: email), so
					// authentication.getName() resolves the EntryStore username consistently in the
					// success handler and SetUserURIAfterAuthenticationFilter.
					.userInfoEndpoint(userInfo -> userInfo.oidcUserService(new UsernameClaimOidcUserService(oidcAuthService)))
					.successHandler(oidcHandler)
					.failureHandler(new SsoLoginFailureHandler("OIDC", oidcConfiguration.redirectFailure().url())));
			var oidcRateLimiter = oidcLoginRateLimiter.orElseThrow(() -> new IllegalStateException(
					"OIDC is enabled but OidcLoginRateLimiter bean is missing — check the " +
							"entrystore.auth.oidc.enabled binding."));
			http.addFilterBefore(SsoLoginRateLimitFilter.forOidc(oidcRateLimiter, oidcAuthService),
					OAuth2AuthorizationRequestRedirectFilter.class);
		} else {
			log.info("OIDC Auth Disabled");
		}

		return http.build();
	}

	@Bean
	public AuthenticationEntryPoint customEntryPoint() {
		return (request, response, authException) -> {
			// Delegate the exception to global Exception handler, falling back to a direct envelope
			// write if the resolver returns null (no handler matched) so the client never gets an
			// empty committed response.
			var mv = handlerExceptionResolver.resolveException(request, response, null, authException);
			if (mv == null && !response.isCommitted()) {
				log.warn("AuthenticationEntryPoint: exception resolver did not handle {}", authException.getClass().getName());
				errorResponseWriter.writeErrorResponseAsJson(response, ErrorResponse.builder()
						.status(HttpStatus.UNAUTHORIZED.value())
						.path(request.getRequestURI())
						.error(HttpStatus.UNAUTHORIZED.getReasonPhrase())
						.build());
			}
		};
	}

	@Bean
	public AccessDeniedHandler customAccessDeniedHandler() {
		return (request, response, accessDeniedException) -> {
			// Delegate the exception to global Exception handler (AppExceptionHandler.handleAccessDeniedException),
			// falling back to a direct envelope write if the resolver returns null.
			var mv = handlerExceptionResolver.resolveException(request, response, null, accessDeniedException);
			if (mv == null && !response.isCommitted()) {
				log.warn("AccessDeniedHandler: exception resolver did not handle {}", accessDeniedException.getClass().getName());
				errorResponseWriter.writeErrorResponseAsJson(response, ErrorResponse.builder()
						.status(HttpStatus.FORBIDDEN.value())
						.path(request.getRequestURI())
						.error(HttpStatus.FORBIDDEN.getReasonPhrase())
						.build());
			}
		};
	}

	/** HTTP Basic carries a password, so password login off disables it too, as in 5.x. */
	private boolean isHttpBasicEnabled() {
		return httpBasicConfig.enabled() && passwordLoginMode != PasswordLoginMode.OFF;
	}

	private AuthenticationEntryPoint authChallengeAwareEntryPoint(AuthenticationEntryPoint delegate) {
		return (request, response, authException) -> {
			if (!"false".equalsIgnoreCase(HttpUtil.getQueryParameter(request, "auth_challenge"))) {
				response.setHeader("Cache-Control", "no-store");
				response.setHeader("WWW-Authenticate", "Basic realm=\"EntryStore\"");
			}
			delegate.commence(request, response, authException);
		};
	}

	@Bean
	public PasswordEncoder passwordEncoder(Ticker ticker) {
		return buildPasswordEncoder(
				isHttpBasicEnabled(),
				httpBasicConfig.cache().ttl(),
				httpBasicConfig.cache().maxSize(),
				ticker);
	}

	/**
	 * The provider of form login and HTTP Basic. It checks the account status after the password, where Spring's
	 * default checks it before, so only a disabled account's correct password throws
	 * {@link DisabledAfterPasswordCheckException} (403 on form login, see
	 * {@link FormLoginAuthenticationFailureHandler}; 401 on HTTP Basic) and a wrong one is rejected like any other. It
	 * must stay the only {@code AuthenticationProvider} bean: Spring Security ignores them all when there are several
	 * and falls back to its default provider.
	 */
	@Bean
	public static DaoAuthenticationProvider passwordAuthenticationProvider(UserDetailsService userDetailsService,
																		   PasswordEncoder passwordEncoder) {
		DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
		provider.setPasswordEncoder(passwordEncoder);
		provider.setPreAuthenticationChecks(user -> {});
		UserDetailsChecker accountStatusChecks = new AccountStatusUserDetailsChecker();
		provider.setPostAuthenticationChecks(user -> {
			if (!user.isEnabled()) {
				throw new DisabledAfterPasswordCheckException();
			}
			accountStatusChecks.check(user);
		});
		return provider;
	}

	// Pure function of its arguments — no bean state — so it stays static and unit-testable
	// directly from SecurityConfigTest without standing up the full @Configuration bean graph.
	static PasswordEncoder buildPasswordEncoder(boolean basicAuthEnabled, Duration ttl, long maxSize, Ticker ticker) {
		PasswordEncoder pbkdf2 = pbkdf2PasswordEncoder();
		return basicAuthEnabled
				? new CachingPasswordEncoder(pbkdf2, ttl, maxSize, ticker)
				: pbkdf2;
	}

	private static PasswordEncoder pbkdf2PasswordEncoder() {
		return new PasswordEncoder() {
			@Override
			public String encode(CharSequence rawPassword) {
				return Password.getSaltedHash(rawPassword.toString());
			}

			@Override
			public boolean matches(CharSequence rawPassword, String encodedPassword) {
				try {
					return Password.check(rawPassword.toString(), encodedPassword);
				} catch (IllegalArgumentException e) {
					return false;
				}
			}
		};
	}

	private Saml2AuthenticationRequestResolver createCustomResolver() {
		var registrationResolver = new DefaultRelyingPartyRegistrationResolver(registrationRepository());
		var resolver = new OpenSaml5AuthenticationRequestResolver(registrationResolver);

		resolver.setRelayStateResolver(samlRelayStateResolver);

		return resolver;
	}

	/**
	 * The converter Spring would build by default, but matching {@code acsMatcher}. A converter passed to
	 * the configurer is used as is, so the request repository has to be set here as well.
	 */
	private OpenSaml5AuthenticationTokenConverter createAcsTokenConverter(SamlAcsRequestMatcher acsMatcher) {
		var converter = new OpenSaml5AuthenticationTokenConverter(registrationRepository());
		converter.setRequestMatcher(acsMatcher);
		converter.setAuthenticationRequestRepository(saml2AuthenticationRequestRepository);
		return converter;
	}

	private RelyingPartyRegistrationRepository registrationRepository() {
		return repo.orElseThrow(() -> new IllegalStateException(
				"RelyingPartyRegistrationRepository was not injected - missing SAML2 autoconfiguration?"));
	}

	@Bean
	public FilterRegistrationBean<CheckUsernamePasswordFilter> disableCheckUsernamePasswordFilterAutoRegistration(CheckUsernamePasswordFilter f) {
		FilterRegistrationBean<CheckUsernamePasswordFilter> reg = new FilterRegistrationBean<>(f);
		reg.setEnabled(false);
		return reg;
	}

	@Bean
	public FilterRegistrationBean<SetUserURIAfterAuthenticationFilter> disableSetUserURIAfterAuthenticationFilterAutoRegistration(SetUserURIAfterAuthenticationFilter f) {
		FilterRegistrationBean<SetUserURIAfterAuthenticationFilter> reg = new FilterRegistrationBean<>(f);
		reg.setEnabled(false);
		return reg;
	}

	@Bean
	public FilterRegistrationBean<ReloadUserPropertiesFilter> disableReloadUserPropertiesFilterAutoRegistration(ReloadUserPropertiesFilter f) {
		FilterRegistrationBean<ReloadUserPropertiesFilter> reg = new FilterRegistrationBean<>(f);
		reg.setEnabled(false);
		return reg;
	}

	@Bean
	public FilterRegistrationBean<IgnoreAuthFilter> disableIgnoreAuthFilterAutoRegistration(IgnoreAuthFilter f) {
		FilterRegistrationBean<IgnoreAuthFilter> reg = new FilterRegistrationBean<>(f);
		reg.setEnabled(false);
		return reg;
	}

	@Bean
	public FilterRegistrationBean<CsrfCookieFilter> disableCsrfCookieFilterAutoRegistration() {
		FilterRegistrationBean<CsrfCookieFilter> reg = new FilterRegistrationBean<>(csrfCookieFilter);
		reg.setEnabled(false);
		return reg;
	}

	private CookieCsrfTokenRepository csrfTokenRepository() {
		Objects.requireNonNull(sessionCookieSameSite,
				"init() must run before csrfTokenRepository() — sessionCookieSameSite is null");
		var repo = CookieCsrfTokenRepository.withHttpOnlyFalse();
		repo.setCookieName(csrfCookieName);
		boolean secure = requiresSecureCookie(sessionCookieSecure, sessionCookieSameSite);
		repo.setCookieCustomizer(builder -> builder
				.sameSite(sessionCookieSameSite.attributeValue())
				.secure(secure));
		return repo;
	}

	// Package-private static so SecurityConfigTest can drive the four (secure × sameSite) combinations
	// without constructing the full @RequiredArgsConstructor bean graph. The rule: a cookie must be
	// flagged Secure when the operator explicitly configured Secure OR when SameSite=None (the latter
	// is mandated by all modern browsers; without Secure they silently drop the cookie).
	static boolean requiresSecureCookie(boolean configuredSecure, Cookie.SameSite sameSite) {
		return configuredSecure || sameSite == Cookie.SameSite.NONE;
	}

	// Package-private static so SecurityConfigTest can drive it with a MockEnvironment without
	// constructing the full @RequiredArgsConstructor bean graph.
	static Cookie.SameSite resolveSessionCookieSameSite(Environment environment) {
		// Resolved once in init() and cached in sessionCookieSameSite so csrfTokenRepository() and
		// servletContextInitializer() agree on the same value.
		// Binder applies Spring Boot's relaxed binding (case-insensitive enum match) and throws
		// BindException on typos like "Nonee" — which we surface as a WARN before defaulting to
		// STRICT, so silent misconfiguration cannot leave Secure=false on a cookie the operator
		// intended to flag SameSite=NONE.
		try {
			return Binder.get(environment)
					.bind("server.servlet.session.cookie.same-site", Cookie.SameSite.class)
					.orElse(Cookie.SameSite.STRICT);
		} catch (BindException e) {
			String raw = environment.getProperty("server.servlet.session.cookie.same-site");
			log.warn("Invalid server.servlet.session.cookie.same-site value '{}'; falling back to STRICT. "
					+ "Valid values: NONE, LAX, STRICT.", raw, e);
			return Cookie.SameSite.STRICT;
		}
	}

	static SecurityContextRepository sessionAndRequestContextRepository() {
		var sessionRepository = new SessionContextRepository();
		sessionRepository.setDisableUrlRewriting(true);
		return new DelegatingSecurityContextRepository(sessionRepository, new RequestAttributeSecurityContextRepository());
	}

	@Bean
	public ServletContextInitializer servletContextInitializer() {
		return servletContext -> {
			servletContext.getSessionCookieConfig().setPath(authTokenCookies.getIssuingPath());
			servletContext.getSessionCookieConfig().setMaxAge(authTokenCookies.cookieMaxAgeSeconds());
			if (sessionCookieSameSite == Cookie.SameSite.NONE) {
				servletContext.getSessionCookieConfig().setSecure(true);
			}
		};
	}
}