SamlAcsRequestMatcher.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 jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpMethod;
import org.springframework.security.saml2.core.Saml2ParameterNames;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;

import java.util.Map;

/**
 * Matches the requests that deliver a SAML response: Spring's {@code /login/saml2/sso/{registrationId}} and
 * {@code /login/saml2/sso}, and a POST of a {@code SAMLResponse} to {@code /auth/saml}, the assertion consumer
 * service of EntryStore 5.x that IdPs configured for 5.x still post to. The 5.x URL names the IdP in its
 * {@code idp} query parameter, which is exposed as the same {@code registrationId} variable the Spring path
 * carries, so the token converter resolves the registration from either form.
 *
 * <p>Used as both the processing matcher of {@code Saml2WebSsoAuthenticationFilter} and the converter's
 * matcher; {@link #matcher} must return the delegate's result, since the converter reads the variable from it.
 * Because it replaces the filter's matcher, a {@code loginProcessingUrl} set on the SAML login configurer has no
 * effect. A GET of {@code /auth/saml} is not matched: it starts a login in {@code AuthController}.
 */
public final class SamlAcsRequestMatcher implements RequestMatcher {

	/** The 5.x assertion consumer service path, relative to the context path. */
	public static final String LEGACY_ACS_PATH = "/auth/saml";
	/** The query parameter of the 5.x assertion consumer service URL that names the IdP. */
	public static final String LEGACY_IDP_PARAMETER = "idp";

	private static final String ACS_PATH = Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI;
	private static final String ACS_PATH_WITHOUT_REGISTRATION_ID = "/login/saml2/sso";

	private final RequestMatcher delegate;

	public SamlAcsRequestMatcher() {
		var paths = PathPatternRequestMatcher.withDefaults();
		this.delegate = new OrRequestMatcher(
				paths.matcher(ACS_PATH),
				paths.matcher(ACS_PATH_WITHOUT_REGISTRATION_ID),
				new LegacyAcsRequestMatcher(paths.matcher(HttpMethod.POST, LEGACY_ACS_PATH)));
	}

	/**
	 * Matches a POST to any path a SAML response is delivered to, by method and path alone. Unlike this matcher it
	 * never reads a request parameter, so it can run before the form body may be parsed.
	 */
	public static RequestMatcher postToAnyAcsPath() {
		var paths = PathPatternRequestMatcher.withDefaults();
		return new OrRequestMatcher(
				paths.matcher(HttpMethod.POST, ACS_PATH),
				paths.matcher(HttpMethod.POST, ACS_PATH_WITHOUT_REGISTRATION_ID),
				paths.matcher(HttpMethod.POST, LEGACY_ACS_PATH));
	}

	@Override
	public boolean matches(HttpServletRequest request) {
		return delegate.matches(request);
	}

	@Override
	public MatchResult matcher(HttpServletRequest request) {
		return delegate.matcher(request);
	}

	private record LegacyAcsRequestMatcher(RequestMatcher postToAcsPath) implements RequestMatcher {

		@Override
		public boolean matches(HttpServletRequest request) {
			return matcher(request).isMatch();
		}

		@Override
		public MatchResult matcher(HttpServletRequest request) {
			if (!postToAcsPath.matches(request) || request.getParameter(Saml2ParameterNames.SAML_RESPONSE) == null) {
				return MatchResult.notMatch();
			}
			String idp = request.getParameter(LEGACY_IDP_PARAMETER);
			return StringUtils.hasText(idp) ? MatchResult.match(Map.of("registrationId", idp)) : MatchResult.match();
		}
	}
}