SamlRelayStateResolver.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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.entrystore.rest.springboot.model.auth.AuthState;
import org.entrystore.rest.springboot.service.SamlAuthService;
import org.entrystore.rest.springboot.service.auth.SamlAuthStateCache;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

/**
 * Resolves the SAML {@code RelayState} for an outgoing authentication request: it mints a random
 * token, persists the requested success/failure redirect URLs that pass validation against it, and
 * returns the token to be carried through the IdP round-trip.
 * <p>
 * This runs inside Spring Security's {@code Saml2WebSsoAuthenticationRequestFilter}, which serves
 * {@code /saml2/authenticate/{registrationId}} independently of {@code AuthController}. The redirect
 * URLs are therefore validated against the domain whitelist <em>here</em> — any URL that fails
 * validation is dropped before it reaches the cache, closing the open-redirect bypass where the
 * filter path skipped the controller's validation (ENTRYSTORE-996).
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class SamlRelayStateResolver implements Converter<HttpServletRequest, String> {

	private final SamlAuthService samlAuthService;
	private final SamlAuthStateCache samlAuthStateCache;

	@Override
	public String convert(HttpServletRequest request) {
		String relayStateToken = RandomStringUtils.secure().nextAlphanumeric(16);

		String successUrl = request.getParameter("successurl");
		String failureUrl = request.getParameter("failureurl");

		if (successUrl != null && !samlAuthService.isValidRedirectUrl(successUrl)) {
			log.info("Dropping non-whitelisted SAML success redirect URL: {}", HttpUtil.sanitizeForLog(successUrl));
			successUrl = null;
		}
		if (failureUrl != null && !samlAuthService.isValidRedirectUrl(failureUrl)) {
			log.info("Dropping non-whitelisted SAML failure redirect URL: {}", HttpUtil.sanitizeForLog(failureUrl));
			failureUrl = null;
		}

		if (successUrl != null || failureUrl != null) {
			samlAuthStateCache.storeAuthState(relayStateToken, new AuthState(successUrl, failureUrl));
		}

		return relayStateToken;
	}
}