RefreshableRelyingPartyRegistrationRepository.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 jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import net.shibboleth.shared.resolver.ResolverException;
import org.entrystore.rest.springboot.configuration.ConditionalOnBooleanConfig;
import org.entrystore.rest.springboot.configuration.SamlCustomConfiguration;
import org.entrystore.rest.springboot.util.LogThrottle;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.metadata.resolver.impl.AbstractReloadingMetadataResolver;
import org.opensaml.saml.metadata.resolver.impl.ResourceBackedMetadataResolver;
import org.opensaml.saml.metadata.resolver.index.impl.RoleMetadataIndex;
import org.opensaml.saml.saml2.metadata.SingleSignOnService;
import org.slf4j.event.Level;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyProperties;
import org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyProperties.AssertingParty;
import org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyProperties.AssertingParty.Singlesignon;
import org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyProperties.Registration;
import org.springframework.security.saml2.core.OpenSamlInitializationService;
import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata;
import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadataRepository;
import org.springframework.security.saml2.provider.service.registration.IterableRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.OpenSaml5AssertingPartyMetadataRepository;
import org.springframework.security.saml2.provider.service.registration.OpenSamlAssertingPartyDetails;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* A {@link org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository}
* that re-fetches each IdP's asserting-party metadata at runtime, so signing-certificate rollovers are
* picked up without restarting EntryStore (ENTRYSTORE-1061).
*
* <p>Defining this bean makes Spring Boot's auto-configured repository back off
* ({@code @ConditionalOnMissingBean}); it is only created when SAML is enabled. Each configured
* registration is backed by an OpenSAML {@link ResourceBackedMetadataResolver} that reloads the
* metadata in the background on an expiry-aware schedule, bounded by
* {@code entrystore.auth.saml.idp.<id>.metadata.max-age} (the staleness ceiling, default 7 days). On a
* failed refresh the resolver keeps the previously loaded metadata, so a transient fetch error never
* causes an auth outage. Every {@code use="signing"} certificate in the refreshed metadata becomes a
* verification credential automatically.
*
* <p>An IdP whose {@code http(s)} metadata cannot be fetched at startup does not stop EntryStore from starting: a
* WARN names it, and {@link #findByRegistrationId} returns {@code null} for it until a background retry or a login
* (see {@link #ensureLoaded}) loads the metadata. Unreadable {@code file:} or {@code classpath:} metadata is a
* configuration error and still fails startup.
*
* <p>{@link #findByRegistrationId(String)} builds the {@link RelyingPartyRegistration} fresh from the
* live (background-refreshed) metadata on each call; Spring Security resolves the registration per
* request, so refreshes are picked up by subsequent logins with no re-wiring of the security filter
* chain. The relying-party (SP) side of each registration is mapped as Spring Boot's
* {@code Saml2RelyingPartyRegistrationConfiguration} does, except that a configured single sign-on binding
* is sent to the metadata's endpoint for that binding ({@link #mapSingleSignOn}), and that statically
* configured SP signing/decryption and verification credentials are not applied (EntryStore configures
* none — see {@link #asRegistration} and {@link #warnOnUnsupportedStaticCredentials}).
*/
@Slf4j
@Component
@ConditionalOnBooleanConfig("entrystore.auth.saml.enabled")
public class RefreshableRelyingPartyRegistrationRepository implements IterableRelyingPartyRegistrationRepository {
// Minimum delay between background metadata refreshes — matches OpenSAML's own default (5 min) so a
// short cacheDuration/validUntil in the IdP metadata cannot drive polling faster than this. Clamped to
// max-age (which can be as low as 60s) so the resolver never gets minRefreshDelay > maxRefreshDelay.
private static final long MIN_REFRESH_DELAY_SECONDS = 300L;
// How often a login may trigger a fetch of metadata that has never loaded: often enough that a recovered IdP is
// usable on the next login, rarely enough that anonymous logins cannot turn an outage into a fetch storm.
static final Duration ON_DEMAND_REFRESH_INTERVAL = Duration.ofSeconds(30);
private final Saml2RelyingPartyProperties relyingPartyProperties;
private final SamlCustomConfiguration samlConfiguration;
private final Ticker ticker;
// One self-refreshing metadata source per registration id; the resolvers are kept so their background reload
// threads can be stopped on shutdown.
private final Map<String, IdpMetadata> idps = new ConcurrentHashMap<>();
// Every login rebuilds its registration, so a missing single sign-on endpoint is warned about once per id.
private final Set<String> missingSsoEndpointWarned = ConcurrentHashMap.newKeySet();
public RefreshableRelyingPartyRegistrationRepository(Saml2RelyingPartyProperties relyingPartyProperties,
SamlCustomConfiguration samlConfiguration, Ticker ticker) {
this.relyingPartyProperties = relyingPartyProperties;
this.samlConfiguration = samlConfiguration;
this.ticker = ticker;
}
@PostConstruct
void initialize() {
OpenSamlInitializationService.initialize();
Map<String, Registration> registrations = relyingPartyProperties.getRegistration();
if (registrations.isEmpty()) {
throw new IllegalStateException(noRegistrationsMessage());
}
registrations.forEach((id, registration) -> {
String metadataUri = registration.getAssertingparty().getMetadataUri();
if (!StringUtils.hasText(metadataUri)) {
throw new IllegalStateException("Missing assertingparty.metadata-uri for SAML registration '" + id + "'");
}
warnOnUnsupportedStaticCredentials(id, registration);
long maxAge = maxAgeSeconds(id);
AbstractReloadingMetadataResolver resolver = buildRefreshingResolver(id, metadataUri, maxAge);
var idp = new IdpMetadata(resolver, new OpenSaml5AssertingPartyMetadataRepository(resolver),
new LogThrottle(ON_DEMAND_REFRESH_INTERVAL, ticker::read), new AtomicBoolean());
idps.put(id, idp);
if (idp.isLoaded()) {
log.info("SAML IdP metadata for registration '{}' auto-refreshes (max-age {}s) from {}", id, maxAge, metadataUri);
} else {
log.warn("SAML IdP metadata for registration '{}' could not be loaded from {}; SAML login with it fails "
+ "until the metadata is reachable. Retrying every {}s, and on login at most every {}s.",
id, metadataUri, minRefreshDelaySeconds(maxAge), ON_DEMAND_REFRESH_INTERVAL.toSeconds());
}
});
}
@PreDestroy
void shutdown() {
idps.values().forEach(idp -> {
try {
idp.resolver().destroy();
} catch (RuntimeException e) {
log.warn("Failed to stop SAML metadata resolver '{}' during shutdown", idp.resolver().getId(), e);
}
});
}
@Override
public RelyingPartyRegistration findByRegistrationId(String registrationId) {
IdpMetadata idp = idps.get(registrationId);
if (idp == null) {
return null;
}
if (!ensureLoaded(registrationId, idp)) {
log.debug("No IdP metadata loaded for SAML registration '{}'", registrationId);
return null;
}
Registration registration = relyingPartyProperties.getRegistration().get(registrationId);
AssertingPartyMetadata metadata = resolveAssertingPartyMetadata(registrationId, idp.assertingParties(),
registration.getAssertingparty().getEntityId());
if (metadata == null) {
return null;
}
return asRegistration(registrationId, registration, metadata);
}
/**
* Whether {@code registrationId} is configured but no registration can be built for it, because its IdP metadata
* has not loaded or lacks the configured asserting party: a login with it cannot start.
*/
public boolean isConfiguredButUnavailable(String registrationId) {
return registrationId != null && idps.containsKey(registrationId) && findByRegistrationId(registrationId) == null;
}
@Override
public Iterator<RelyingPartyRegistration> iterator() {
return idps.keySet().stream()
.map(this::findByRegistrationId)
.filter(Objects::nonNull)
.iterator();
}
/**
* The registration whose asserting party has {@code entityId}, or {@code null} when none or more than one
* has. The SAML response converter falls back to this for an IdP-initiated response posted to a URL that
* names no registration, such as the 5.x single-IdP assertion consumer service URL; the inherited default
* would look the entity id up as a registration id.
*/
@Override
public RelyingPartyRegistration findUniqueByAssertingPartyEntityId(String entityId) {
// The indexed metadata lookup first, so only registrations whose IdP metadata has that entity are built. No
// fetch: any anonymous ACS POST gets here, and would fetch every unloaded IdP in turn.
List<RelyingPartyRegistration> matches = idps.entrySet().stream()
.filter(entry -> entry.getValue().isLoaded())
.filter(entry -> entry.getValue().assertingParties().findByEntityId(entityId) != null)
.map(entry -> findByRegistrationId(entry.getKey()))
.filter(registration -> registration != null
&& entityId.equals(registration.getAssertingPartyMetadata().getEntityId()))
.limit(2)
.toList();
return matches.size() == 1 ? matches.getFirst() : null;
}
/**
* Whether the IdP's metadata has loaded, fetching it first if it never has and the registration's
* {@link #ON_DEMAND_REFRESH_INTERVAL} has passed. Only the caller that wins the throttle fetches; the others,
* and any caller while that fetch is still running, return at once instead of waiting for it.
*/
private boolean ensureLoaded(String registrationId, IdpMetadata idp) {
// Unlocked read: refresh() holds the resolver's lock for the whole fetch, which logins must not wait on.
if (idp.isLoaded() || !idp.onDemandRefresh().tryAcquire() || !idp.fetching().compareAndSet(false, true)) {
return idp.isLoaded();
}
AbstractReloadingMetadataResolver resolver = idp.resolver();
try {
// refresh() and destroy() lock the resolver; refresh() after destroy() throws, so re-check under the lock.
synchronized (resolver) {
if (!resolver.isDestroyed() && !idp.isLoaded()) {
resolver.refresh();
}
}
} catch (ResolverException e) {
log.warn("SAML IdP metadata for registration '{}' is still unavailable; retrying on login at most every "
+ "{}s: {}", registrationId, ON_DEMAND_REFRESH_INTERVAL.toSeconds(), e.getMessage());
} finally {
idp.fetching().set(false);
}
return idp.isLoaded();
}
private static long minRefreshDelaySeconds(long maxAgeSeconds) {
return Math.min(maxAgeSeconds, MIN_REFRESH_DELAY_SECONDS);
}
private long maxAgeSeconds(String registrationId) {
SamlCustomConfiguration.Idp idp = samlConfiguration.idp().get(registrationId);
return idp != null ? idp.metadata().maxAge() : SamlCustomConfiguration.Idp.Metadata.DEFAULT_MAX_AGE_SECONDS;
}
/**
* Names the IdP ids that EntryStore settings refer to, so an operator whose {@code entrystore.auth.saml.*}
* settings survived an upgrade sees which registrations are missing rather than only the Spring key prefix.
*/
private String noRegistrationsMessage() {
Set<String> idpIds = new TreeSet<>(samlConfiguration.idp().keySet());
if (StringUtils.hasText(samlConfiguration.defaultIdp())) {
idpIds.add(samlConfiguration.defaultIdp());
}
String message = "SAML is enabled but no relying-party registrations are configured "
+ "(spring.security.saml2.relyingparty.registration.*).";
if (idpIds.isEmpty()) {
return message;
}
return message + " entrystore.auth.saml.* refers to the IdP ids " + idpIds + "; set "
+ "spring.security.saml2.relyingparty.registration.<id>.assertingparty.metadata-uri for each of them.";
}
/**
* Builds an OpenSAML resolver that reloads the metadata in the background, capped at {@code maxAge}.
* Mirrors how {@code OpenSaml5AssertingPartyMetadataRepository.withTrustedMetadataLocation} constructs
* its resolver (a {@link ResourceBackedMetadataResolver} over a Spring resource), adding the
* refresh-interval and require-valid-metadata configuration the Spring builder does not expose.
*
* <p>Uses the OpenSAML 5 backend (Spring Security 7); the rest of this class uses version-neutral
* Spring Security APIs.
*/
private AbstractReloadingMetadataResolver buildRefreshingResolver(String id, String metadataUri, long maxAgeSeconds) {
try {
SpringMetadataResource resource = SpringMetadataResource.forLocation(metadataUri);
ResourceBackedMetadataResolver resolver = new ResourceBackedMetadataResolver(resource);
resolver.setId("entrystore-saml-idp-" + id);
resolver.setParserPool(XMLObjectProviderRegistrySupport.getParserPool());
// Required so the asserting-party repository can resolve/iterate IDPSSODescriptor entities.
resolver.setIndexes(Set.of(new RoleMetadataIndex()));
resolver.setMaxRefreshDelay(Duration.ofSeconds(maxAgeSeconds));
resolver.setMinRefreshDelay(Duration.ofSeconds(minRefreshDelaySeconds(maxAgeSeconds)));
resolver.setRequireValidMetadata(true);
// initialize() performs the initial fetch. An unreachable IdP must not stop EntryStore from starting, so a
// remote resolver then starts empty and retries; unreadable local metadata is a configuration error.
resolver.setFailFastInitialization(!resource.isRemote());
resolver.initialize();
return resolver;
} catch (Exception e) {
throw new IllegalStateException(
"Failed to initialize auto-refreshing SAML metadata resolver for registration '" + id + "'", e);
}
}
private AssertingPartyMetadata resolveAssertingPartyMetadata(String registrationId,
AssertingPartyMetadataRepository metadataRepository,
String configuredEntityId) {
if (StringUtils.hasText(configuredEntityId)) {
AssertingPartyMetadata metadata = metadataRepository.findByEntityId(configuredEntityId);
if (metadata == null) {
log.warn("SAML registration '{}' configures assertingparty.entity-id '{}', which is not present in the "
+ "current IdP metadata; SAML login for this registration will fail until the metadata or the "
+ "configured entity-id is corrected.", registrationId, configuredEntityId);
}
return metadata;
}
Iterator<AssertingPartyMetadata> iterator = metadataRepository.iterator();
if (!iterator.hasNext()) {
log.warn("Current IdP metadata for SAML registration '{}' contains no asserting party; SAML login for this "
+ "registration will fail.", registrationId);
return null;
}
AssertingPartyMetadata first = iterator.next();
if (iterator.hasNext()) {
log.warn("Metadata for SAML registration '{}' contains multiple asserting parties; using the first. "
+ "Configure spring.security.saml2.relyingparty.registration.{}.assertingparty.entity-id "
+ "to select one explicitly.", registrationId, registrationId);
}
return first;
}
/**
* Maps the relying-party (SP) side onto the freshly resolved asserting-party metadata. Kept aligned
* with Spring Boot's {@code Saml2RelyingPartyRegistrationConfiguration#asRegistration} so behaviour
* matches the auto-configuration this bean replaces, except for the single sign-on endpoint of a
* configured binding ({@link #mapSingleSignOn}). Static SP signing/decryption credentials and
* statically configured verification credentials are intentionally not applied here — EntryStore's SAML
* SP carries none (verification credentials come from the IdP metadata); see
* {@link #warnOnUnsupportedStaticCredentials}.
*/
private RelyingPartyRegistration asRegistration(String id, Registration properties, AssertingPartyMetadata metadata) {
return RelyingPartyRegistration.withAssertingPartyMetadata(metadata)
.registrationId(id)
.entityId(properties.getEntityId())
.assertionConsumerServiceLocation(properties.getAcs().getLocation())
.assertionConsumerServiceBinding(properties.getAcs().getBinding())
.assertingPartyMetadata(mapAssertingParty(id, properties.getAssertingparty(), metadata))
.singleLogoutServiceLocation(properties.getSinglelogout().getUrl())
.singleLogoutServiceResponseLocation(properties.getSinglelogout().getResponseUrl())
.singleLogoutServiceBinding(properties.getSinglelogout().getBinding())
.nameIdFormat(properties.getNameIdFormat())
.build();
}
// Copied from Spring Boot's Saml2RelyingPartyRegistrationConfiguration#mapAssertingParty, except for the
// single sign-on endpoint: overrides the metadata-derived asserting-party fields with any explicitly
// configured property (non-null only).
private Consumer<AssertingPartyMetadata.Builder<?>> mapAssertingParty(String id, AssertingParty assertingParty,
AssertingPartyMetadata metadata) {
return (details) -> {
// Boot 4's PropertyMapper filters null source values by default, so the former
// alwaysApplyingWhenNonNull() is no longer needed (and was removed).
PropertyMapper map = PropertyMapper.get();
map.from(assertingParty::getEntityId).to(details::entityId);
mapSingleSignOn(id, assertingParty.getSinglesignon(), metadata, details);
map.from(assertingParty.getSinglesignon()::getSignRequest).to(details::wantAuthnRequestsSigned);
map.from(assertingParty.getSinglelogout()::getUrl).to(details::singleLogoutServiceLocation);
map.from(assertingParty.getSinglelogout()::getResponseUrl).to(details::singleLogoutServiceResponseLocation);
map.from(assertingParty.getSinglelogout()::getBinding).to(details::singleLogoutServiceBinding);
};
}
/**
* Applies the configured single sign-on binding together with the metadata's endpoint for it, unless a URL
* is configured too. Spring Boot overrides only the binding and keeps the URL of the metadata's first POST
* or Redirect endpoint, so with an IdP that publishes one URL per binding the request would reach the wrong
* endpoint. Without an endpoint for the configured binding, it is sent to that first endpoint's URL, and a WARN,
* logged once per registration, says so.
*/
private void mapSingleSignOn(String id, Singlesignon singleSignOn, AssertingPartyMetadata metadata,
AssertingPartyMetadata.Builder<?> details) {
Saml2MessageBinding binding = singleSignOn.getBinding();
String url = singleSignOn.getUrl();
if (binding != null && url == null) {
url = singleSignOnLocation(metadata, binding);
if (url == null) {
log.atLevel(missingSsoEndpointWarned.add(id) ? Level.WARN : Level.DEBUG).log("IdP metadata for SAML "
+ "registration '{}' has no single sign-on endpoint with the configured {} binding; "
+ "sending it to {}. Set ...singlesignon.url to choose the endpoint.",
id, binding, metadata.getSingleSignOnServiceLocation());
}
}
PropertyMapper map = PropertyMapper.get();
map.from(binding).to(details::singleSignOnServiceBinding);
map.from(url).to(details::singleSignOnServiceLocation);
}
private static String singleSignOnLocation(AssertingPartyMetadata metadata, Saml2MessageBinding binding) {
if (!(metadata instanceof OpenSamlAssertingPartyDetails openSamlMetadata)) {
return null;
}
return openSamlMetadata.getEntityDescriptor().getIDPSSODescriptor(SAMLConstants.SAML20P_NS)
.getSingleSignOnServices().stream()
.filter(service -> binding.getUrn().equals(service.getBinding()))
.map(SingleSignOnService::getLocation)
.filter(StringUtils::hasText)
.findFirst()
.orElse(null);
}
/**
* The metadata source of one registration and the gates on the fetches its logins trigger: a fetch can outlast
* the throttle interval, and a second one would only wait on the resolver's lock.
*/
private record IdpMetadata(AbstractReloadingMetadataResolver resolver,
AssertingPartyMetadataRepository assertingParties,
LogThrottle onDemandRefresh,
AtomicBoolean fetching) {
// lastUpdate is set only when metadata is actually loaded, unlike the refresh-success flags.
boolean isLoaded() {
return resolver.getLastUpdate() != null;
}
}
private void warnOnUnsupportedStaticCredentials(String id, Registration registration) {
boolean hasStaticCredentials = !registration.getSigning().getCredentials().isEmpty()
|| !registration.getDecryption().getCredentials().isEmpty()
|| !registration.getAssertingparty().getVerification().getCredentials().isEmpty();
if (hasStaticCredentials) {
log.warn("SAML registration '{}' configures static signing/decryption/verification credentials, which the "
+ "auto-refreshing metadata repository does not apply; IdP verification credentials are taken from "
+ "the refreshed metadata. Remove them or extend RefreshableRelyingPartyRegistrationRepository.", id);
}
}
}