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) {}
}