SamlCustomConfiguration.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.configuration;

import org.entrystore.rest.springboot.util.CaseFolding;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

import java.time.Duration;
import java.util.List;
import java.util.Map;

/**
 * EntryStore-specific SAML login settings. Deliberate asymmetry inside this record:
 * {@code idp.{id}.domains} is case-folded at binding because a case-mismatched domain silently
 * misroutes logins, while {@code redirectDomainWhitelist} stays unfolded and shape-unchecked — a
 * whitelist mismatch only rejects a redirect (fail-closed), and installed SAML deployments rely on
 * the current matching. This intentionally differs from {@link OidcCustomConfiguration}, which
 * case-folds both surfaces and shape-checks its whitelist (its {@code provider.{id}.domains}
 * deliberately accepts {@code *} for routing, which its whitelist rejects).
 *
 * <p>{@code requestLifetime} is how long a started login may stay at the IdP: the saved authentication request
 * and the relay-state redirect URLs expire after it, and Spring rejects a response whose {@code InResponseTo}
 * names an expired request. It must cover MFA, consent and password changes at the IdP.
 */
@ConfigurationProperties(prefix = "entrystore.auth.saml")
public record SamlCustomConfiguration(
		@DefaultValue("false") boolean enabled,
		String defaultIdp,
		List<String> redirectDomainWhitelist,
		Map<String, Idp> idp,
		RedirectUrl redirectSuccess,
		RedirectUrl redirectFailure,
		Duration requestLifetime
) {
	private static final Duration DEFAULT_REQUEST_LIFETIME = Duration.ofMinutes(15);
	private static final Duration MIN_REQUEST_LIFETIME = Duration.ofMinutes(1);
	// Both request caches are anonymously writable; a longer lifetime lowers the request rate that fills them.
	private static final Duration MAX_REQUEST_LIFETIME = Duration.ofHours(1);

	public SamlCustomConfiguration {
		if (redirectSuccess == null) redirectSuccess = new RedirectUrl("/auth/user");
		if (redirectFailure == null) redirectFailure = new RedirectUrl("/auth/user");
		// Copy the bound collections so a consumer cannot mutate the redirect whitelist or IdP routing.
		redirectDomainWhitelist = redirectDomainWhitelist == null ? List.of() : List.copyOf(redirectDomainWhitelist);
		idp = idp == null ? Map.of() : Map.copyOf(idp);
		if (requestLifetime == null) requestLifetime = DEFAULT_REQUEST_LIFETIME;
		if (requestLifetime.compareTo(MIN_REQUEST_LIFETIME) < 0 || requestLifetime.compareTo(MAX_REQUEST_LIFETIME) > 0) {
			throw new IllegalArgumentException("entrystore.auth.saml.request-lifetime must be between "
					+ MIN_REQUEST_LIFETIME.toMinutes() + "m and " + MAX_REQUEST_LIFETIME.toMinutes() + "m, got "
					+ requestLifetime.toSeconds() + "s");
		}
	}

	// Per-IdP configuration, keyed in the map by the IdP id (entrystore.auth.saml.idp.{id}.*).
	public record Idp(
			@DefaultValue("*") List<String> domains,
			@DefaultValue("false") boolean userAutoProvisioning,
			Metadata metadata
	) {
		public Idp {
			// Copy + lowercase: the routing lookup compares against a case-folded request domain —
			// an uppercase config entry would otherwise silently misroute to the wildcard IdP.
			domains = domains == null ? List.of("*") : domains.stream().map(CaseFolding::toLowerCase).toList();
			metadata = metadata == null ? new Metadata(Metadata.DEFAULT_MAX_AGE_SECONDS) : metadata;
		}

		// Asserting-party (IdP) metadata refresh settings; binds entrystore.auth.saml.idp.{id}.metadata.*.
		// max-age is the staleness ceiling in seconds before the IdP metadata is re-fetched at runtime,
		// so signing-certificate rollovers are picked up without a restart (revived for ENTRYSTORE-1061).
		public record Metadata(@DefaultValue("604800") long maxAge) {
			// 604800 s = 7 days, the legacy default refresh interval (Settings.AUTH_SAML_IDP_METADATA_MAXAGE).
			// Keep the @DefaultValue literal above in sync with this constant.
			public static final long DEFAULT_MAX_AGE_SECONDS = 604800L;
			// Refreshing IdP metadata more often than this would hammer the federation endpoint; below it
			// is treated as a misconfiguration rather than a useful refresh ceiling.
			public static final long MIN_MAX_AGE_SECONDS = 60L;

			public Metadata {
				if (maxAge < MIN_MAX_AGE_SECONDS) {
					throw new IllegalArgumentException(
							"entrystore.auth.saml.idp.<id>.metadata.max-age must be at least "
									+ MIN_MAX_AGE_SECONDS + " seconds");
				}
			}
		}
	}

	public record RedirectUrl(String url) {}
}