OidcCustomConfiguration.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 lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.rest.springboot.util.CaseFolding;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;

import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * EntryStore-specific OIDC login settings, mirroring the structure of {@link SamlCustomConfiguration}.
 * The client registration itself (client id/secret, scopes, issuer) is configured via Spring Boot's
 * native {@code spring.security.oauth2.client.registration.{id}.*} / {@code ...client.provider.{id}.*}
 * properties — the same split SAML uses with {@code spring.security.saml2.relyingparty.*}.
 */
@Slf4j
@ConfigurationProperties(prefix = "entrystore.auth.oidc")
public record OidcCustomConfiguration(
		@DefaultValue("false") boolean enabled,
		String defaultProvider,
		List<String> redirectDomainWhitelist,
		Map<String, Provider> provider,
		RedirectUrl redirectSuccess,
		RedirectUrl redirectFailure
) {
	/**
	 * How long a started login may stay at the provider: the authorization request and its redirect URLs expire
	 * after it.
	 */
	public static final Duration REQUEST_LIFETIME = Duration.ofMinutes(2);

	/**
	 * Normalizes and validates the bound values so misconfiguration fails at binding, naming the
	 * key, instead of surfacing later and vaguer. The bound collections are copied so a consumer
	 * cannot mutate the whitelist or provider routing. Whitelist entries are trimmed and blank
	 * elements (a trailing or doubled comma) dropped rather than rejected — Spring trims only the
	 * comma-delimited binding form, and this record binds even when OIDC is disabled, so rejecting
	 * a formatting artifact would abort unrelated deployments; a configured-but-all-blank list is
	 * warned about, since it silently ignores every caller-supplied redirect. The surviving entries
	 * are lowercased (hostnames are case-insensitive; {@code OidcAuthService} compares the
	 * case-folded request-side host) and must round-trip through {@code URI.getHost()} unchanged —
	 * the invariant the lookup depends on — so any entry that lookup could never match (scheme,
	 * port, path, userinfo, wildcards, inner spaces, hosts the URI parser rejects) fails at binding
	 * instead of silently dropping every caller-supplied {@code successurl}/{@code failureurl}.
	 * A bracketed IPv6 literal ({@code [::1]}) round-trips and is therefore allowed. A blank
	 * {@code default-provider} is normalized to null so downstream sees one "not configured" state
	 * instead of a whitespace id failing per-request.
	 */
	public OidcCustomConfiguration {
		if (redirectSuccess == null) redirectSuccess = new RedirectUrl("/auth/user");
		if (redirectFailure == null) redirectFailure = new RedirectUrl("/auth/user");
		defaultProvider = StringUtils.trimToNull(defaultProvider);
		boolean whitelistConfigured = redirectDomainWhitelist != null && !redirectDomainWhitelist.isEmpty();
		redirectDomainWhitelist = !whitelistConfigured ? List.of()
				: redirectDomainWhitelist.stream()
						.map(StringUtils::trimToNull)
						.filter(Objects::nonNull)
						.map(CaseFolding::toLowerCase)
						.toList();
		if (whitelistConfigured && redirectDomainWhitelist.isEmpty()) {
			log.warn("entrystore.auth.oidc.redirect-domain-whitelist is configured but every entry is blank — "
					+ "the whitelist is empty, so all caller-supplied successurl/failureurl values will be ignored");
		}
		for (String host : redirectDomainWhitelist) {
			String parsed;
			try {
				parsed = URI.create("https://" + host).getHost();
			} catch (IllegalArgumentException e) {
				parsed = null;
			}
			if (!host.equals(parsed)) {
				throw new IllegalArgumentException("entrystore.auth.oidc.redirect-domain-whitelist entries "
						+ "must be single bare hostnames (or bracketed IPv6 literals) that URI.getHost() can "
						+ "return, without scheme, port, path, userinfo or wildcards; got: '" + host + "'");
			}
		}
		provider = provider == null ? Map.of() : Map.copyOf(provider);
	}

	// Per-provider configuration, keyed in the map by the provider id (entrystore.auth.oidc.provider.{id}.*).
	// The id must match the Spring client-registration id (spring.security.oauth2.client.registration.{id}.*).
	public record Provider(
			@DefaultValue("*") List<String> domains,
			@DefaultValue("false") boolean userAutoProvisioning,
			@DefaultValue(DEFAULT_USERNAME_CLAIM) String usernameClaim
	) {
		/** Claim mapped to the EntryStore username when {@code username-claim} is not configured. */
		public static final String DEFAULT_USERNAME_CLAIM = "email";

		public Provider {
			// Copy + lowercase: the routing lookup compares against a case-folded request domain —
			// an uppercase config entry would otherwise silently misroute to the wildcard/default.
			domains = domains == null ? List.of("*")
					: domains.stream().map(CaseFolding::toLowerCase).toList();
			// Blank covers programmatic construction; property binding applies the @DefaultValue on null.
			usernameClaim = usernameClaim == null || usernameClaim.isBlank() ? DEFAULT_USERNAME_CLAIM : usernameClaim;
		}
	}

	/**
	 * Rejects blank and syntactically invalid values at binding, naming the key. A blank binding
	 * ({@code entrystore.auth.oidc.redirect-success.url=}) bypasses the null-only defaulting in the
	 * outer constructor; downstream, {@code SecurityConfig}'s {@code setDefaultTargetUrl} and
	 * {@code SimpleUrlAuthenticationFailureHandler} reject it at bean creation anyway — but only
	 * when OIDC is enabled, and without naming the offending key. A malformed URL would otherwise
	 * defer failure to the first post-login redirect at runtime.
	 */
	public record RedirectUrl(String url) {
		public RedirectUrl {
			if (url == null || url.isBlank()) {
				throw new IllegalArgumentException(
						"entrystore.auth.oidc redirect-success.url/redirect-failure.url must not be blank when configured");
			}
			try {
				URI.create(url);
			} catch (IllegalArgumentException e) {
				throw new IllegalArgumentException(
						"entrystore.auth.oidc redirect-success.url/redirect-failure.url is not a valid URI: " + url, e);
			}
		}
	}
}