AuthTokenCookies.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 jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.Getter;
import org.entrystore.repository.RepositoryManager;
import org.entrystore.rest.springboot.filter.CacheControlFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.convert.DurationStyle;
import org.springframework.boot.web.server.Cookie;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.WebUtils;

import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * Owns the session cookie (auth_token): its path and lifetime, the login lifetime of the session behind it, and
 * expiring the cookie when it is unknown, expired or logged out.
 *
 * <p>As in 5.x, a login lasts {@code entrystore.auth.cookie.max-age} (or a shorter {@code auth_maxage}). With
 * {@code entrystore.auth.cookie.refresh-expiration-on-access} on (the default) that is the session's idle timeout,
 * so an active user stays logged in, and the cookie lives 365 days because the container sends it only at login.
 * With it off, the cookie lives max-age and the login ends max-age after it was made, whatever the activity.
 *
 * <p>The cookie is issued on {@code entrystore.auth.cookie.path}; its default {@code auto} is the path of the
 * repository base URL (e.g. {@code /store/}), as in 5.x. Browsers identify a cookie by name, domain and path, and may
 * hold one on the base-URL path (5.x) and one on the servlet context path (earlier 6.0 builds), each host-only (5.x)
 * or on the configured domain. The client often cannot remove them (HttpOnly, cross-site), so each is expired with
 * the SameSite, Secure and HttpOnly the container uses ({@code server.servlet.session.cookie.*}); without
 * SameSite=None a browser drops a cross-site expiry.
 */
@Component
public class AuthTokenCookies implements LogoutHandler {

	private static final String EXPIRED_ATTRIBUTE = AuthTokenCookies.class.getName() + ".EXPIRED";

	static final String LOGIN_EXPIRY_ATTRIBUTE = AuthTokenCookies.class.getName() + ".LOGIN_EXPIRY";

	private static final int REFRESHED_COOKIE_MAX_AGE = (int) Duration.ofDays(365).toSeconds();

	private final String cookieName;
	private final boolean httpOnly;
	private final boolean secure;
	private final String sameSite;
	@Getter
	private final String issuingPath;
	private final List<Target> allTargets;
	private final List<Target> staleTargets;
	private final int maxAgeSeconds;
	@Getter
	private final boolean refreshExpirationOnAccess;

	/** A cookie identity, which browsers compose of name, domain and path; a null domain is host-only. */
	private record Target(String domain, String path) {}

	public AuthTokenCookies(RepositoryManager repositoryManager, Environment environment,
							@Value("${server.servlet.session.cookie.name:auth_token}") String cookieName,
							@Value("${server.servlet.session.cookie.domain:}") String domain,
							@Value("${server.servlet.session.cookie.http-only:true}") boolean httpOnly,
							@Value("${server.servlet.session.cookie.secure:true}") boolean configuredSecure,
							@Value("${server.servlet.context-path:}") String contextPath,
							@Value("${entrystore.auth.cookie.path:auto}") String configuredPath,
							@Value("${entrystore.auth.cookie.max-age:86400}") String maxAge,
							@Value("${entrystore.auth.cookie.refresh-expiration-on-access:true}") boolean refreshExpirationOnAccess) {
		Cookie.SameSite resolvedSameSite = SecurityConfig.resolveSessionCookieSameSite(environment);
		this.cookieName = cookieName;
		this.httpOnly = httpOnly;
		this.secure = SecurityConfig.requiresSecureCookie(configuredSecure, resolvedSameSite);
		this.sameSite = resolvedSameSite.attributeValue();
		this.issuingPath = resolveIssuingPath(configuredPath, repositoryManager.getRepositoryURL());
		// 5.x never set a Domain, so its host-only cookie must be expired even when a Domain is configured
		String issuingDomain = domain.isBlank() ? null : domain;
		var domains = issuingDomain == null ? Arrays.asList((String) null) : Arrays.asList(issuingDomain, null);
		this.allTargets = expiryPaths(issuingPath, contextPath).stream()
				.flatMap(path -> domains.stream().map(d -> new Target(d, path)))
				.toList();
		var issued = new Target(issuingDomain, issuingPath);
		this.staleTargets = allTargets.stream().filter(target -> !target.equals(issued)).toList();
		this.maxAgeSeconds = parseMaxAgeSeconds(maxAge);
		this.refreshExpirationOnAccess = refreshExpirationOnAccess;
	}

	/**
	 * @return the cookie's Max-Age: 365 days with refresh-expiration-on-access, otherwise the login max-age
	 */
	public int cookieMaxAgeSeconds() {
		return refreshExpirationOnAccess ? REFRESHED_COOKIE_MAX_AGE : maxAgeSeconds;
	}

	/**
	 * Sets the login lifetime of the request's session: max-age, or {@code requestedMaxAge} if it is positive and
	 * shorter. Call on every successful login, after the session id has changed.
	 *
	 * @param requestedMaxAge the client's {@code auth_maxage} in seconds, or null
	 */
	public void applySessionLifetime(HttpServletRequest request, Integer requestedMaxAge) {
		int lifetime = requestedMaxAge != null && requestedMaxAge > 0
				? Math.min(maxAgeSeconds, requestedMaxAge)
				: maxAgeSeconds;
		HttpSession session = request.getSession();
		session.setMaxInactiveInterval(lifetime);
		if (!refreshExpirationOnAccess) {
			session.setAttribute(LOGIN_EXPIRY_ATTRIBUTE, Instant.now().plusSeconds(lifetime));
		}
	}

	/**
	 * @return when the session's login ends: after its idle timeout from now with refresh-expiration-on-access,
	 * otherwise at the fixed time set at login
	 */
	public Instant expiry(HttpSession session) {
		if (!refreshExpirationOnAccess && session.getAttribute(LOGIN_EXPIRY_ATTRIBUTE) instanceof Instant expiry) {
			return expiry;
		}
		return Instant.now().plusSeconds(session.getMaxInactiveInterval());
	}

	/**
	 * @return whether the session's login has passed its fixed end; always false with refresh-expiration-on-access
	 */
	boolean isLoginExpired(HttpSession session) {
		return !refreshExpirationOnAccess
				&& session.getAttribute(LOGIN_EXPIRY_ATTRIBUTE) instanceof Instant expiry
				&& Instant.now().isAfter(expiry);
	}

	/** Expires the cookie on all paths it may have been issued on, if the request carries it. */
	public void expireAll(HttpServletRequest request, HttpServletResponse response) {
		expire(request, response, allTargets);
	}

	/**
	 * Expires the cookie everywhere except where the container issues it, if the request carries it. Used on login,
	 * where the container sets the new session cookie on the issuing path in the same response.
	 */
	public void expireStale(HttpServletRequest request, HttpServletResponse response) {
		expire(request, response, staleTargets);
	}

	/**
	 * Expires the cookie on all paths it may have been issued on, also if the request does not carry it: for a login
	 * that failed after the container issued the new session's cookie in this response.
	 */
	public void expireAfterFailedLogin(HttpServletRequest request, HttpServletResponse response) {
		expire(request, response, allTargets, true);
	}

	@Override
	public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
		expireAll(request, response);
	}

	private void expire(HttpServletRequest request, HttpServletResponse response, List<Target> targets) {
		expire(request, response, targets, false);
	}

	private void expire(HttpServletRequest request, HttpServletResponse response, List<Target> targets,
						boolean evenIfNotSent) {
		if (!evenIfNotSent && WebUtils.getCookie(request, cookieName) == null) {
			return;
		}
		// On an ended session, ConcurrentSessionFilter's logout and InvalidSessionCookieFilter may both expire it
		@SuppressWarnings("unchecked")
		var expired = (Set<Target>) request.getAttribute(EXPIRED_ATTRIBUTE);
		if (expired == null) {
			expired = new HashSet<>();
			request.setAttribute(EXPIRED_ATTRIBUTE, expired);
		}
		for (Target target : targets) {
			if (!expired.add(target)) {
				continue;
			}
			var cookie = ResponseCookie.from(cookieName, "")
					.domain(target.domain())
					.path(target.path())
					.maxAge(0)
					.httpOnly(httpOnly)
					.secure(secure)
					.sameSite(sameSite);
			response.addHeader(HttpHeaders.SET_COOKIE, cookie.build().toString());
		}
		// A guest request continues to a handler that may commit before CacheControlFilter's post-chain check.
		CacheControlFilter.markSessionCookieResponse(response);
	}

	/**
	 * Parses max-age as seconds unless it carries a unit (e.g. {@code 1d}), as Spring Boot does for durations.
	 *
	 * @throws IllegalStateException unless it is at least one second and fits a session timeout
	 */
	static int parseMaxAgeSeconds(String maxAge) {
		Duration duration;
		try {
			duration = DurationStyle.detectAndParse(maxAge.trim(), ChronoUnit.SECONDS);
		} catch (IllegalArgumentException e) {
			throw new IllegalStateException("Invalid entrystore.auth.cookie.max-age '" + maxAge + "'", e);
		}
		long seconds = duration.toSeconds();
		if (seconds < 1 || seconds > Integer.MAX_VALUE) {
			throw new IllegalStateException("entrystore.auth.cookie.max-age must be between 1 and "
					+ Integer.MAX_VALUE + " seconds, was '" + maxAge + "'");
		}
		return (int) seconds;
	}

	static String resolveIssuingPath(String configuredPath, URL repositoryUrl) {
		if (configuredPath != null && !configuredPath.isBlank() && !"auto".equalsIgnoreCase(configuredPath.trim())) {
			return configuredPath.trim();
		}
		String path = repositoryUrl.getPath();
		return path.isEmpty() ? "/" : path;
	}

	/** Returns each of the issuing and the context path both without and with a trailing slash. */
	static List<String> expiryPaths(String issuingPath, String contextPath) {
		var paths = new LinkedHashSet<String>();
		for (String path : List.of(issuingPath, contextPath.isBlank() ? "/" : contextPath)) {
			String withoutSlash = path.endsWith("/") ? path.substring(0, path.length() - 1) : path;
			paths.add(withoutSlash.isEmpty() ? "/" : withoutSlash);
			paths.add(withoutSlash + "/");
		}
		return List.copyOf(paths);
	}
}