SpringMetadataResource.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 net.shibboleth.shared.resource.Resource;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.UrlResource;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.time.Duration;

/**
 * Adapts a Spring {@link org.springframework.core.io.Resource} to the Shibboleth
 * {@link Resource} expected by OpenSAML's {@code ResourceBackedMetadataResolver}, so SAML IdP
 * metadata can be loaded uniformly from {@code classpath:}, {@code file:} and {@code https:}
 * locations through Spring's resource loading (and JDK URL connections) without a separate HTTP
 * client. {@link RefreshableRelyingPartyRegistrationRepository} needs its own adapter (Spring Security's
 * {@code SpringResource} is private) to configure the resolver's refresh interval.
 *
 * <p>An {@code http(s)} location is {@linkplain #isRemote() remote} and differs from a local one in three ways:
 * <ul>
 *   <li>connections time out ({@link #CONNECT_TIMEOUT}, {@link #READ_TIMEOUT}), so an unresponsive IdP neither
 *       stalls startup nor holds the resolver's lock, which logins wait on, indefinitely;</li>
 *   <li>{@link #exists()} is {@code true}: the resolver's constructor checks it, and an IdP that is down at
 *       startup must not fail the application — the fetch reports unreachability instead;</li>
 *   <li>{@link #lastModified()} is the current time: the resolver downloads only when it is later than the last
 *       refresh attempt, and Spring reports 0 when the IdP sends no {@code Last-Modified} header, so the metadata
 *       would never be (re)loaded after a failed first fetch, nor refreshed from such an IdP.</li>
 * </ul>
 */
class SpringMetadataResource implements Resource {

	private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
	private static final Duration READ_TIMEOUT = Duration.ofSeconds(30);

	private final org.springframework.core.io.Resource resource;

	private SpringMetadataResource(org.springframework.core.io.Resource resource) {
		this.resource = resource;
	}

	/**
	 * The metadata at {@code location}: an {@code http(s)} URL becomes a {@linkplain #isRemote() remote} resource
	 * with the default timeouts; any other location is resolved by Spring's {@link DefaultResourceLoader}.
	 */
	static SpringMetadataResource forLocation(String location) throws MalformedURLException {
		if (location.regionMatches(true, 0, "http:", 0, 5) || location.regionMatches(true, 0, "https:", 0, 6)) {
			return remote(URI.create(location), CONNECT_TIMEOUT, READ_TIMEOUT);
		}
		return new SpringMetadataResource(new DefaultResourceLoader().getResource(location));
	}

	static SpringMetadataResource remote(URI uri, Duration connectTimeout, Duration readTimeout)
			throws MalformedURLException {
		return new SpringMetadataResource(new TimeLimitedUrlResource(uri, connectTimeout, readTimeout));
	}

	boolean isRemote() {
		return resource instanceof TimeLimitedUrlResource;
	}

	@Override
	public boolean exists() {
		return isRemote() || this.resource.exists();
	}

	@Override
	public boolean isReadable() {
		return this.resource.isReadable();
	}

	@Override
	public boolean isOpen() {
		return this.resource.isOpen();
	}

	@Override
	public URL getURL() throws IOException {
		return this.resource.getURL();
	}

	@Override
	public URI getURI() throws IOException {
		return this.resource.getURI();
	}

	@Override
	public File getFile() throws IOException {
		return this.resource.getFile();
	}

	@Override
	public InputStream getInputStream() throws IOException {
		return this.resource.getInputStream();
	}

	@Override
	public long contentLength() throws IOException {
		return this.resource.contentLength();
	}

	@Override
	public long lastModified() throws IOException {
		return isRemote() ? System.currentTimeMillis() : this.resource.lastModified();
	}

	@Override
	public Resource createRelativeResource(String relativePath) throws IOException {
		return new SpringMetadataResource(this.resource.createRelative(relativePath));
	}

	@Override
	public String getFilename() {
		return this.resource.getFilename();
	}

	@Override
	public String getDescription() {
		return this.resource.getDescription();
	}

	/**
	 * A {@link UrlResource} whose HTTP connections time out. Every connection it opens — the metadata fetch and
	 * the HEAD requests behind {@code exists()}, {@code contentLength()} and {@code lastModified()} — passes
	 * through {@link #customizeConnection(HttpURLConnection)}.
	 */
	private static final class TimeLimitedUrlResource extends UrlResource {

		private final int connectTimeoutMillis;
		private final int readTimeoutMillis;

		TimeLimitedUrlResource(URI uri, Duration connectTimeout, Duration readTimeout) throws MalformedURLException {
			super(uri);
			this.connectTimeoutMillis = Math.toIntExact(connectTimeout.toMillis());
			this.readTimeoutMillis = Math.toIntExact(readTimeout.toMillis());
		}

		@Override
		protected void customizeConnection(HttpURLConnection con) throws IOException {
			super.customizeConnection(con);
			con.setConnectTimeout(connectTimeoutMillis);
			con.setReadTimeout(readTimeoutMillis);
		}
	}
}