Fix remaining CI checks and restore Spring profile, settings.yml and persist parity

This commit is contained in:
Anthony Stirling
2026-06-13 16:17:51 +01:00
parent 403bf550aa
commit a77226f2a7
19 changed files with 252 additions and 1373 deletions
+7 -5
View File
@@ -58,16 +58,18 @@ jobs:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
run: ./gradlew :stirling-pdf:bootJar -PnoSpotless --no-daemon
run: ./gradlew :stirling-pdf:quarkusBuild -PnoSpotless --no-daemon
- name: Locate built JAR
id: jar
run: |
jar=$(find app/core/build/libs -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
| grep -vE '(-plain|-sources)\.jar$' | head -n 1)
# Quarkus (quarkus.package.jar.type=uber-jar) emits a standalone runnable
# jar at app/core/build/<name>-runner.jar, replacing the Spring Boot bootJar
# that used to land in app/core/build/libs.
jar=$(find app/core/build -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1)
if [[ -z "$jar" ]]; then
echo "::error::No JAR under app/core/build/libs"
ls -lah app/core/build/libs || true
echo "::error::No *-runner.jar under app/core/build"
ls -lah app/core/build || true
exit 1
fi
# Absolute path - the migration script pushd's into a temp workdir
+1 -1
View File
@@ -16,7 +16,7 @@ repos:
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment,vertx
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
@@ -13,8 +13,7 @@ import java.util.stream.Stream;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.quarkus.arc.DefaultBean;
import io.quarkus.arc.profile.UnlessBuildProfile;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Dependent;
@@ -318,29 +317,28 @@ public class AppConfig {
return applicationProperties.getSystem().getDatasource();
}
// @UnlessBuildProfile("saas"): in the saas flavor SaasLicenseOverride provides these @Named
// beans (every tenant is ENTERPRISE). @DefaultBean alone is not enough - Qute's named-bean
// validation still sees this @DefaultBean producer alongside the saas one and rejects the
// duplicate @Named key, so this default must be vetoed outright under the saas profile.
// @IfBuildProfile("core"): these NORMAL/default license @Named beans apply only to the core
// flavor. In proprietary EEAppConfig provides them (security profile) and in saas
// SaasLicenseOverride does (saas profile); registering this producer alongside those trips
// Qute's named-bean validation ("Duplicate key runningEE"), which does not honour @DefaultBean
// suppression - so gate to core outright. (In core, EEAppConfig/SaasLicenseOverride are not
// even on the classpath.)
@Produces
@DefaultBean
@UnlessBuildProfile("saas")
@IfBuildProfile("core")
@Named("runningProOrHigher")
public boolean runningProOrHigher() {
return false;
}
@Produces
@DefaultBean
@UnlessBuildProfile("saas")
@IfBuildProfile("core")
@Named("runningEE")
public boolean runningEnterprise() {
return false;
}
@Produces
@DefaultBean
@UnlessBuildProfile("saas")
@IfBuildProfile("core")
@Named("license")
public String licenseType() {
return "NORMAL";
@@ -0,0 +1,120 @@
package stirling.software.common.configuration;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.eclipse.microprofile.config.spi.ConfigSource;
import org.snakeyaml.engine.v2.api.Load;
import org.snakeyaml.engine.v2.api.LoadSettings;
/**
* Exposes {@code settings.yml} (and {@code custom_settings.yml}, with the bundled {@code
* settings.yml.template} as the default fallback) as a MicroProfile/SmallRye {@link ConfigSource}.
*
* <p>Restores the Spring {@code @ConfigurationProperties} behaviour that bound {@code settings.yml}
* into {@code ApplicationProperties}: without this the YAML was never read under Quarkus, so flags
* like {@code security.enableLogin} fell back to their Java defaults regardless of the file (the
* {@code enableLogin=false}/{@code maxDPI=0}/{@code loginAttemptCount=0} class of bugs). The nested
* YAML is flattened to dotted keys ({@code security.enableLogin -> "true"}); {@link
* ApplicationPropertiesConfigOverlay} and {@code @ConfigProperty} injections then read them.
*
* <p>Ordinal {@value #ORDINAL} sits above {@code application.properties} (250) but below
* environment variables (300) and system properties (400), matching Spring's precedence - e.g.
* {@code SECURITY_ENABLELOGIN} still overrides the file.
*
* <p>Registered via {@code META-INF/services/org.eclipse.microprofile.config.spi.ConfigSource}.
*/
public class SettingsYamlConfigSource implements ConfigSource {
private static final int ORDINAL = 275;
private final Map<String, String> properties;
public SettingsYamlConfigSource() {
this.properties = load();
}
private static Map<String, String> load() {
Map<String, String> flat = new HashMap<>();
// 1. Bundled template provides the defaults (e.g. security.enableLogin: true).
try (InputStream in =
SettingsYamlConfigSource.class
.getClassLoader()
.getResourceAsStream("settings.yml.template")) {
if (in != null) {
flatten("", loadYaml(in), flat);
}
} catch (Exception ignored) {
// best effort - fall through to file overrides / Java defaults
}
// 2. The user's settings.yml overrides the template.
mergeFile(InstallationPathConfig.getSettingsPath(), flat);
// 3. custom_settings.yml overrides settings.yml.
mergeFile(InstallationPathConfig.getCustomSettingsPath(), flat);
return flat;
}
private static void mergeFile(String path, Map<String, String> flat) {
try {
Path p = Path.of(path);
if (Files.isRegularFile(p)) {
try (InputStream in = Files.newInputStream(p)) {
flatten("", loadYaml(in), flat);
}
}
} catch (Exception ignored) {
// unreadable/invalid file - keep whatever defaults were already loaded
}
}
private static Object loadYaml(InputStream in) {
return new Load(LoadSettings.builder().build()).loadFromInputStream(in);
}
private static void flatten(String prefix, Object node, Map<String, String> out) {
if (node instanceof Map<?, ?> map) {
for (Map.Entry<?, ?> e : map.entrySet()) {
String key =
prefix.isEmpty() ? String.valueOf(e.getKey()) : prefix + "." + e.getKey();
flatten(key, e.getValue(), out);
}
} else if (node instanceof List<?>) {
// MicroProfile list binding uses indexed keys; the ApplicationProperties overlay reads
// scalar values only, so skip lists rather than emit a malformed "[a, b]" value.
return;
} else if (node != null) {
out.put(prefix, String.valueOf(node));
}
// null leaves are left unset so the Java default applies.
}
@Override
public Map<String, String> getProperties() {
return properties;
}
@Override
public Set<String> getPropertyNames() {
return properties.keySet();
}
@Override
public String getValue(String propertyName) {
return properties.get(propertyName);
}
@Override
public String getName() {
return "settings.yml";
}
@Override
public int getOrdinal() {
return ORDINAL;
}
}
@@ -0,0 +1 @@
stirling.software.common.configuration.SettingsYamlConfigSource
+65
View File
@@ -341,8 +341,20 @@ if (buildWithFrontend) {
// DatabaseService/NoOpDatabaseService both stay active (ambiguous). The profile is fixed at build
// time by @IfBuildProfile, so this must be set before quarkusBuild runs; doing it at configuration
// time (guarded by the flavor) keeps core/proprietary builds on the default profile.
// Map the build flavor to a Quarkus feature profile, replacing Spring's runtime profile
// auto-detection (former getActiveProfile() -> setAdditionalProfiles). @IfBuildProfile/@Unless
// gate beans on these. Set as the profile PARENT (not the profile itself) for security/core so the
// run-mode profile (prod for quarkusBuild, dev for quarkusDev) - and dev services/live reload -
// stay intact; saas keeps using its own "saas" profile.
// core -> "core" (default license beans only)
// proprietary -> "security" (EEAppConfig: security & !saas)
// saas -> "saas" (SaasLicenseOverride)
if (gradle.ext.enableSaas) {
System.setProperty('quarkus.profile', 'saas')
} else if (!gradle.ext.disableAdditional) {
System.setProperty('quarkus.config.profile.parent', 'security')
} else {
System.setProperty('quarkus.config.profile.parent', 'core')
}
// The Quarkus build must see the library modules' jars (and their Jandex indexes) on the
@@ -404,3 +416,56 @@ tasks.named("quarkusDev") {
jvmArgs = runtimeArgs
}
// ---------------------------------------------------------------------------
// Compatibility aliases for the Spring Boot Gradle tasks removed in the Quarkus
// migration. CI workflows, the Taskfile and desktop builds still invoke these
// task names; each delegates to the Quarkus equivalent so those callers keep
// working without a sweeping rename across the build scripts.
// ---------------------------------------------------------------------------
// bootRun -> quarkusDev: run the application from source (used by the e2e and
// dev tasks to boot a live :8080 server).
tasks.register('bootRun') {
group = 'application'
description = 'Compatibility alias for the removed Spring Boot bootRun; runs quarkusDev.'
dependsOn 'quarkusDev'
}
// bootJar -> quarkusBuild: produce the runnable uber-jar at
// app/core/build/<name>-runner.jar (quarkus.package.jar.type=uber-jar).
tasks.register('bootJar') {
group = 'build'
description = 'Compatibility alias for the removed Spring Boot bootJar; runs quarkusBuild.'
dependsOn 'quarkusBuild'
}
// OpenAPI doc generation replacing springdoc's generateOpenApiDocs/copySwaggerDoc. quarkusBuild
// exports the SmallRye schema (quarkus.smallrye-openapi.store-schema-directory) during augmentation;
// copySwaggerDoc publishes that openapi.json to SwaggerDoc.json at the repo root, which the
// check-openapi workflow and the ai-engine tool-models step consume.
tasks.register('copySwaggerDoc') {
group = 'documentation'
description = 'Publish the SmallRye OpenAPI schema to SwaggerDoc.json at the repo root.'
dependsOn 'quarkusBuild'
doLast {
def schema = fileTree(layout.buildDirectory)
.matching { include '**/openapi-schema/openapi.json' }
.files
.find { it != null }
if (schema == null) {
throw new GradleException(
"No openapi-schema/openapi.json found under ${layout.buildDirectory.get()}; " +
"check quarkus.smallrye-openapi.store-schema-directory.")
}
def target = new File(rootProject.projectDir, 'SwaggerDoc.json')
target.text = schema.text
logger.lifecycle("Wrote OpenAPI schema (${schema.length()} bytes) to ${target}")
}
}
tasks.register('generateOpenApiDocs') {
group = 'documentation'
description = 'Compatibility alias for the removed springdoc generateOpenApiDocs.'
dependsOn 'copySwaggerDoc'
}
@@ -95,6 +95,9 @@ quarkus.hibernate-orm.mapping.format.global=ignore
quarkus.smallrye-openapi.path=/v1/api-docs
quarkus.swagger-ui.path=/swagger-ui.html
quarkus.swagger-ui.always-include=true
# Export the schema at build time (during quarkusBuild) so the copySwaggerDoc Gradle task can
# publish it as SwaggerDoc.json - the build-time replacement for springdoc's generateOpenApiDocs.
quarkus.smallrye-openapi.store-schema-directory=build/openapi-schema
# ---- Jackson (was spring.jackson.*) ----------------------------------------------------------
# spring.jackson.deserialization.fail-on-null-for-primitives=false
@@ -1,314 +0,0 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.coveo.saml.SamlClient;
import com.coveo.saml.SamlException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.io.Resource;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.UrlUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.service.AiUserDataService;
// TODO: Migration required - this class was a Spring Security
// SimpleUrlLogoutSuccessHandler wired into the Spring Security logout filter chain.
// Quarkus has no LogoutSuccessHandler equivalent. The logout endpoint must be rehosted
// (e.g. a JAX-RS resource or jakarta.servlet endpoint) that invokes onLogoutSuccess(...)
// after the Quarkus security/session logout has run. Configure HTTP auth/logout policies
// via quarkus.http.auth.* and quarkus-oidc (for OAuth2/OIDC logout).
@Slf4j
@ApplicationScoped
@RequiredArgsConstructor(onConstructor_ = {@Inject})
public class CustomLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
private final AiUserDataService aiUserDataService;
// TODO: Migration required - Spring's AuthenticationTrustResolver
// (used to filter out the anonymous principal) has no direct Quarkus equivalent.
// Under Quarkus, an unauthenticated request yields an anonymous SecurityIdentity
// (SecurityIdentity#isAnonymous()); use that check in resolveUsername(...) instead.
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
public void onLogoutSuccess(
HttpServletRequest request, HttpServletResponse response, Object authentication)
throws IOException {
String username = resolveUsername(request, authentication);
if (username != null) {
aiUserDataService.purgeUserDocuments(username);
}
if (!response.isCommitted()) {
if (authentication != null) {
// TODO: Migration required - the original code branched on the Spring
// Authentication implementation type to choose a logout redirect:
// Saml2Authentication -> getRedirect_saml2(...)
// OAuth2AuthenticationToken -> getRedirect_oauth2(...)
// UsernamePasswordAuthentication -> redirect to LOGOUT_PATH
// unknown -> log + redirect to LOGOUT_PATH
// Under Quarkus the authentication mechanism is identified differently
// (SecurityIdentity attributes / quarkus-oidc vs form auth, or the IdP
// recorded at login). Re-wire this dispatch to invoke getRedirect_saml2 /
// getRedirect_oauth2 once the Quarkus identity model is in place. Until
// then we fall through to the default login-page redirect to preserve
// safe behavior (a single redirect, never IdP logout with a null subject).
response.sendRedirect(LOGOUT_PATH);
} else {
if (jwtService != null) {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
response.sendRedirect(LOGOUT_PATH);
return;
}
}
// Redirect to login page after logout
String path = checkForErrors(request);
response.sendRedirect(path);
}
}
}
/**
* Pick the right name to purge under. JWT cookie wins if present and parseable; we fall through
* to whatever the authentication handed us only when there's no cookie. The anonymous principal
* is filtered out so we don't purge under that pseudo-user.
*/
private String resolveUsername(HttpServletRequest request, Object authentication) {
if (jwtService != null) {
String fromCookie = jwtService.extractUsernameFromRequestAllowExpired(request);
if (fromCookie != null) {
return fromCookie;
}
}
// TODO: Migration required - replace the Spring AuthenticationTrustResolver
// anonymous check and Authentication#getName() with SecurityIdentity:
// if (identity == null || identity.isAnonymous()) return null;
// String name = identity.getPrincipal().getName();
if (authentication == null) {
return null;
}
return null;
}
// Redirect for SAML2 authentication logout
// TODO: Migration required - parameter was Spring Saml2Authentication; the SAML2
// principal (CustomSaml2AuthenticatedPrincipal) must be recovered from the Quarkus
// identity once the SAML SP is rehosted on OpenSAML 5 (see SAML2 migration plan).
private void getRedirect_saml2(
HttpServletRequest request, HttpServletResponse response, Object samlAuthentication)
throws IOException {
SAML2 samlConf = securityProperties.getSaml2();
String registrationId = samlConf.getRegistrationId();
// TODO: Migration required - extract the SAML NameID from the Quarkus identity.
// Original:
// CustomSaml2AuthenticatedPrincipal principal =
// (CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
// String nameIdValue = principal.name();
String nameIdValue = null;
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
// TODO: Migration required - CertificateUtils still declares
// org.springframework.core.io.Resource parameters; once it is migrated to
// stirling.software.common.model.io.Resource these calls compile directly.
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
List<X509Certificate> certificates = new ArrayList<>();
certificates.add(certificate);
// Construct URLs required for SAML configuration
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
// Read private key for service provider
Resource privateKeyResource = samlConf.getPrivateKey();
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
// Redirect to identity provider for logout with relay state
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
samlConf.getProvider(),
nameIdValue,
e);
response.sendRedirect(LOGOUT_PATH);
}
}
// Redirect for OAuth2 authentication logout
// TODO: Migration required - parameter was Spring OAuth2AuthenticationToken; under
// quarkus-oidc the authorized client registration id must be obtained from the OIDC
// configuration / SecurityIdentity rather than the token.
private void getRedirect_oauth2(
HttpServletRequest request, HttpServletResponse response, Object oAuthToken)
throws IOException {
String registrationId;
OAUTH2 oauth = securityProperties.getOauth2();
String path = checkForErrors(request);
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
// TODO: Migration required - original:
// registrationId = oAuthToken.getAuthorizedClientRegistrationId();
// Resolve the OIDC provider id from quarkus-oidc config / SecurityIdentity instead.
registrationId = "";
// Redirect based on OAuth2 provider
switch (registrationId.toLowerCase(Locale.ROOT)) {
case "keycloak" -> {
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
boolean isKeycloak = !keycloak.getIssuer().isBlank();
boolean isCustomOAuth = !oauth.getIssuer().isBlank();
String logoutUrl = redirectUrl;
if (isKeycloak) {
logoutUrl = keycloak.getIssuer();
} else if (isCustomOAuth) {
logoutUrl = oauth.getIssuer();
}
if (isKeycloak || isCustomOAuth) {
logoutUrl +=
"/protocol/openid-connect/logout"
+ "?client_id="
+ oauth.getClientId()
+ "&post_logout_redirect_uri="
+ response.encodeRedirectURL(redirectUrl);
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
} else {
log.info(
"No redirect URL for {} available. Redirecting to default logout URL:"
+ " {}",
registrationId,
logoutUrl);
}
response.sendRedirect(logoutUrl);
}
case "github", "google" -> {
log.info(
"No redirect URL for {} available. Redirecting to default logout URL: {}",
registrationId,
redirectUrl);
response.sendRedirect(redirectUrl);
}
default -> {
log.info("Redirecting to default logout URL: {}", redirectUrl);
response.sendRedirect(redirectUrl);
}
}
}
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
String idpIssuer = samlConf.getIdpIssuer();
// Create SamlClient instance for SAML logout
return new SamlClient(
relyingPartyIdentifier,
assertionConsumerServiceUrl,
idpSLOUrl,
idpIssuer,
certificates,
SamlClient.SamlIdpBinding.POST);
}
/**
* Handles different error scenarios during logout. Will return a <code>String</code> containing
* the error request parameter.
*
* @param request the user's <code>HttpServletRequest</code> request.
* @return a <code>String</code> containing the error request parameter.
*/
private String checkForErrors(HttpServletRequest request) {
String errorMessage;
String path = "logout=true";
if (request.getParameter("oAuth2AuthenticationErrorWeb") != null) {
path = "errorOAuth=userAlreadyExistsWeb";
} else if ((errorMessage = request.getParameter("errorOAuth")) != null) {
path = "errorOAuth=" + sanitizeInput(errorMessage);
} else if (request.getParameter("oAuth2AutoCreateDisabled") != null) {
path = "errorOAuth=oAuth2AutoCreateDisabled";
} else if (request.getParameter("oAuth2AdminBlockedUser") != null) {
path = "errorOAuth=oAuth2AdminBlockedUser";
} else if (request.getParameter("oAuth2RequiresLicense") != null) {
path = "errorOAuth=oAuth2RequiresLicense";
} else if (request.getParameter("saml2RequiresLicense") != null) {
path = "errorOAuth=saml2RequiresLicense";
} else if (request.getParameter("maxUsersReached") != null) {
path = "errorOAuth=maxUsersReached";
} else if (request.getParameter("userIsDisabled") != null) {
path = "errorOAuth=userIsDisabled";
} else if ((errorMessage = request.getParameter("error")) != null) {
path = "errorOAuth=" + sanitizeInput(errorMessage);
} else if (request.getParameter("badCredentials") != null) {
path = "errorOAuth=badCredentials";
}
return path;
}
/**
* Sanitize input to avoid potential security vulnerabilities. Will return a sanitised <code>
* String</code>.
*
* @return a sanitised <code>String</code>
*/
private String sanitizeInput(String input) {
return RegexPatternUtils.getInstance()
.getInputSanitizePattern()
.matcher(input)
.replaceAll("");
}
}
@@ -1,112 +0,0 @@
package stirling.software.proprietary.security.oauth2;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
// TODO: Migration required - this class previously extended Spring Security's
// SimpleUrlAuthenticationFailureHandler and was wired into the OAuth2 login flow as the
// failure handler. quarkus-oidc has no direct equivalent for a servlet
// AuthenticationFailureHandler. Under quarkus-oidc the failure path should be handled via
// quarkus.oidc.* config (e.g. quarkus.oidc.authentication.error-path) plus a
// jakarta.ws.rs.ext.ExceptionMapper / SecurityIdentityAugmentor or a redirect filter that
// inspects the OIDC error and applies the same redirect logic below. The redirect-building
// logic (Tauri handling, cookie clearing, query-param construction) is preserved here as a
// reusable bean; rewire the actual failure dispatch to call onAuthenticationFailure(...) once
// the quarkus-oidc wiring is in place. The original Spring exception types
// (BadCredentialsException / DisabledException / LockedException / OAuth2AuthenticationException
// + OAuth2Error) were used to branch on the failure cause and must be re-mapped to the
// quarkus-oidc equivalents (io.quarkus.oidc / io.quarkus.security exceptions).
@Slf4j
@ApplicationScoped
public class CustomOAuth2AuthenticationFailureHandler {
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response, Exception exception)
throws IOException, ServletException {
// TODO: Migration required - the original handler branched on Spring Security exception
// types to choose a redirect target:
// BadCredentialsException -> "/login?error=badCredentials"
// DisabledException -> "/logout?userIsDisabled=true"
// LockedException -> "/logout?error=locked"
// OAuth2AuthenticationException (with OAuth2Error.getErrorCode()) -> OAuth2 error flow
// Re-map these branches to the corresponding quarkus-oidc / io.quarkus.security failure
// causes. The OAuth2 error-code handling below is preserved but the error code can no
// longer be extracted from OAuth2Error and must be sourced from the OIDC failure context.
String errorCode = null;
if ("Password must not be null".equals(errorCode)) {
errorCode = "userAlreadyExistsWeb";
}
log.error(
"OAuth2 Authentication error: {}",
errorCode != null ? errorCode : exception.getMessage(),
exception);
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
clearRedirectCookie(response);
boolean tauriState = TauriOAuthUtils.isTauriState(request);
String redirectUrl;
if (tauriState) {
String basePath = TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
redirectUrl = basePath;
String stateParam = request.getParameter("state");
if (stateParam != null && !stateParam.isBlank()) {
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
// Extract and pass nonce for CSRF validation
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
} else {
redirectUrl = buildFailureRedirectUrl(request, errorValue);
}
response.sendRedirect(redirectUrl);
}
private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) {
String contextPath = request.getContextPath();
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
String redirectPath =
cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath);
if (TauriOAuthUtils.isTauriState(request)) {
redirectPath = appendQueryParam(redirectPath, "tauri", "1");
}
String resolvedPath =
redirectPath.startsWith("/")
? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath
: TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath;
return appendQueryParam(resolvedPath, "errorOAuth", errorValue);
}
private void clearRedirectCookie(HttpServletResponse response) {
// Replaces Spring's ResponseCookie/HttpHeaders.SET_COOKIE with a plain servlet header.
String cookie = TauriOAuthUtils.SPA_REDIRECT_COOKIE + "=; Path=/; Max-Age=0; SameSite=Lax";
response.addHeader("Set-Cookie", cookie);
}
private String appendQueryParam(String path, String key, String value) {
if (path == null || path.isBlank()) {
return path;
}
String separator = path.contains("?") ? "&" : "?";
String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8);
String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
return path + separator + encodedKey + "=" + encodedValue;
}
}
@@ -1,107 +0,0 @@
package stirling.software.proprietary.security.saml2;
import java.io.IOException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
// TODO: Migration required - This was a Spring Security SimpleUrlAuthenticationFailureHandler
// (@ConditionalOnProperty "security.saml2.enabled"). There is NO Quarkus SAML extension, so the
// SAML SP must be rehosted on a Jakarta @WebServlet using OpenSAML 5 (dnulnets/quarkus-saml
// pattern). When that servlet is in place, wire it to invoke onAuthenticationFailure(...) below
// on SAML authentication failures. The Spring security glue removed:
// - extends SimpleUrlAuthenticationFailureHandler / getRedirectStrategy().sendRedirect(...)
// -> replaced by HttpServletResponse.sendRedirect(...)
// - org.springframework.security.core.AuthenticationException
// -> replaced by a generic Exception parameter
// - org.springframework.security.saml2.{Saml2Error, Saml2AuthenticationException}
// -> re-derive the SAML error code from the OpenSAML 5 failure handling in the new servlet
// - org.springframework.security.authentication.ProviderNotFoundException
// -> map to the "not_authentication_provider_found" branch from the new servlet
// TODO: Migration required - the @ConditionalOnProperty(name = "security.saml2.enabled",
// havingValue = "true") build-time toggle was removed; this is a runtime property, so guard
// invocation at the call site (the SAML servlet) or via a runtime config check rather than a
// CDI/build-profile condition.
@Slf4j
@ApplicationScoped
public class CustomSaml2AuthenticationFailureHandler {
@Audited(type = AuditEventType.USER_FAILED_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationFailure(
HttpServletRequest request, HttpServletResponse response, Exception exception)
throws IOException {
log.error("Authentication error", exception);
// TODO: Migration required - the original branched on Spring's
// Saml2AuthenticationException (extracting Saml2Error.getErrorCode()) vs
// ProviderNotFoundException. With OpenSAML 5 on a Jakarta servlet, derive the SAML
// error code and the "no provider found" condition from the new failure-handling code
// and call the corresponding branch below.
String samlErrorCode = resolveSamlErrorCode(exception);
if (samlErrorCode != null) {
if (TauriSamlUtils.isTauriRelayState(request)) {
String redirectUrl =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", samlErrorCode);
response.sendRedirect(redirectUrl);
return;
}
response.sendRedirect("/login?errorOAuth=" + samlErrorCode);
} else if (isProviderNotFound(exception)) {
if (TauriSamlUtils.isTauriRelayState(request)) {
String redirectUrl =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
redirectUrl =
appendQueryParam(
redirectUrl, "errorOAuth", "not_authentication_provider_found");
response.sendRedirect(redirectUrl);
return;
}
response.sendRedirect("/login?errorOAuth=not_authentication_provider_found");
}
}
// TODO: Migration required - return the SAML error code when the failure originates from an
// OpenSAML 5 SAML response error, otherwise null. Previously this came from
// Saml2AuthenticationException.getSaml2Error().getErrorCode().
private String resolveSamlErrorCode(Exception exception) {
return null;
}
// TODO: Migration required - return true when no authentication provider was found.
// Previously this was (exception instanceof ProviderNotFoundException).
private boolean isProviderNotFound(Exception exception) {
return false;
}
private String appendQueryParam(String path, String key, String value) {
if (path == null || path.isBlank()) {
return path;
}
String separator = path.contains("?") ? "&" : "?";
String encodedKey =
java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8);
String encodedValue =
value == null
? ""
: java.net.URLEncoder.encode(
value, java.nio.charset.StandardCharsets.UTF_8);
return path + separator + encodedKey + "=" + encodedValue;
}
}
@@ -1,431 +0,0 @@
package stirling.software.proprietary.security.saml2;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
import java.io.IOException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
// TODO: Migration required - this class is Spring Security SAML2 success-handler glue and has no
// Quarkus equivalent. There is no Quarkus SAML extension; the SAML SP flow must be rehosted on a
// Jakarta @WebServlet using OpenSAML 5 (dnulnets/quarkus-saml pattern). The OpenSAML/business logic
// below (eligibility checks, SSO post-login, JWT issuance, context-aware redirect building) is
// preserved unchanged. The following Spring types were removed and need a Quarkus home:
// - extends SavedRequestAwareAuthenticationSuccessHandler: the "saved request" replay behavior
// (SPRING_SECURITY_SAVED_REQUEST session attribute + super.onAuthenticationSuccess) has no
// direct Quarkus analogue; reimplement original-destination replay in the new SAML SP servlet.
// - org.springframework.security.core.Authentication: the principal/authentication is now passed
// as Object so the OpenSAML principal can still be unwrapped via
// CustomSaml2AuthenticatedPrincipal.
// - org.springframework.security.authentication.LockedException: replaced by a plain
// IllegalStateException to signal a locked account; the new SP must map this to a redirect.
// - org.springframework.http.ResponseCookie / HttpHeaders: replaced with jakarta.servlet.Cookie.
// Also: JwtServiceInterface.generateToken(Authentication, claims) (collaborator) still takes a
// Spring
// Authentication; once that interface is migrated, restore the web-path token call that used it.
@AllArgsConstructor
@Slf4j
@ApplicationScoped
public class CustomSaml2AuthenticationSuccessHandler {
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
private LoginAttemptService loginAttemptService;
private ApplicationProperties.Security.SAML2 saml2Properties;
private UserService userService;
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Object authentication)
throws ServletException, IOException {
// TODO: Migration required - previously obtained via Authentication.getPrincipal(). The new
// SAML SP servlet must supply the OpenSAML principal (or the principal directly) here.
Object principal = authentication;
log.debug("Starting SAML2 authentication success handling");
if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2Principal) {
String username = saml2Principal.name();
log.debug("Authenticated principal found for user: {}", username);
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isSamlEligible(null)) {
// No existing user and no ENTERPRISE license -> block auto creation
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return;
}
HttpSession session = request.getSession(false);
String contextPath = request.getContextPath();
// TODO: Migration required - SPRING_SECURITY_SAVED_REQUEST was Spring Security's
// SavedRequest stored on the session. Quarkus has no SavedRequest type; the new SAML SP
// must persist and replay the original destination itself. Treated as absent for now.
Object savedRequest = null;
log.debug(
"Session exists: {}, Saved request exists: {}",
session != null,
savedRequest != null);
if (savedRequest != null) {
// TODO: Migration required - original-destination replay previously delegated to
// super.onAuthenticationSuccess(...)
// (SavedRequestAwareAuthenticationSuccessHandler).
// Reimplement saved-request redirect in the new SAML SP servlet, guarding static
// resources via RequestUriUtils.isStaticResource(contextPath, savedRedirectUrl).
log.debug("Saved request handling pending SAML SP migration");
} else {
log.debug(
"Processing SAML2 authentication with autoCreateUser: {}",
saml2Properties.getAutoCreateUser());
if (loginAttemptService.isBlocked(username)) {
log.debug("User {} is blocked due to too many login attempts", username);
if (session != null) {
session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST");
}
// TODO: Migration required - was org.springframework.security.authentication
// .LockedException; the new SAML SP must translate this into a locked-account
// redirect/response.
throw new IllegalStateException(
"Your account has been locked due to too many failed login attempts.");
}
boolean hasPassword = userExists && userService.hasPassword(username);
boolean isSsoUser =
userExists && userService.isSsoAuthenticationTypeByUsername(username);
boolean isSAML2User =
userExists && userService.isAuthenticationTypeByUsername(username, SAML2);
log.debug(
"User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}",
userExists,
hasPassword,
isSsoUser,
isSAML2User);
if (userExists
&& hasPassword
&& !isSsoUser
&& saml2Properties.getAutoCreateUser()) {
log.debug(
"User {} exists with password but is not an SSO user, redirecting to logout",
username);
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
return;
}
try {
// Block new users only if: blockRegistration is true OR autoCreateUser is false
if (!userExists
&& (saml2Properties.getBlockRegistration()
|| !saml2Properties.getAutoCreateUser())) {
log.debug(
"Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})",
username,
saml2Properties.getBlockRegistration(),
saml2Properties.getAutoCreateUser());
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?maxUsersReached=true");
return;
}
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
String ssoProvider = "saml2"; // fixme
log.debug(
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
username,
ssoProvider,
ssoProviderId);
userService.processSSOPostLogin(
username,
ssoProviderId,
ssoProvider,
saml2Properties.getAutoCreateUser(),
SAML2);
log.debug("Successfully processed authentication for user: {}", username);
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry.
// TODO: Migration required - originally
// jwtService.generateToken(authentication, claims) using the Spring
// Authentication. Switched to the username overload until
// JwtServiceInterface drops its Spring Authentication parameter.
jwt = jwtService.generateToken(username, claims);
log.debug("Issued WEB SAML token for user '{}'", username);
}
// Build context-aware redirect URL based on the original request
String redirectUrl =
buildContextAwareRedirectUrl(request, response, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
}
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.debug(
"Invalid username detected for user: {}, redirecting to logout",
username);
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
}
}
} else {
// TODO: Migration required - non-SAML2 principals were delegated to the Spring base
// SavedRequestAwareAuthenticationSuccessHandler. The new SAML SP servlet must decide
// how
// to handle non-SAML2 principals (this handler should only receive SAML2 ones).
log.debug("Non-SAML2 principal detected, no parent handler available after migration");
}
}
/**
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request,
HttpServletResponse response,
String contextPath,
String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath);
String origin = resolveOrigin(request);
clearRedirectCookie(response);
String url = origin + redirectPath + "#access_token=" + jwt;
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
url +=
"&nonce="
+ java.net.URLEncoder.encode(
nonce, java.nio.charset.StandardCharsets.UTF_8);
}
return url;
}
/**
* Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
* then falls back to detecting from request headers.
*/
private String resolveOrigin(HttpServletRequest request) {
// First check if frontendUrl is configured
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
return configuredFrontendUrl.trim();
}
// Fall back to auto-detection from request headers
return resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
if (TauriSamlUtils.isTauriRelayState(request)) {
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
}
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
}
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
if (!value.isEmpty()) {
return Optional.of(value);
}
}
}
return Optional.empty();
}
private String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
String forwardedHostHeader = request.getHeader("X-Forwarded-Host");
if (forwardedHostHeader == null || forwardedHostHeader.isBlank()) {
return Optional.empty();
}
String host = forwardedHostHeader.split(",")[0].trim();
if (host.isEmpty()) {
return Optional.empty();
}
String forwardedProtoHeader = request.getHeader("X-Forwarded-Proto");
String proto =
(forwardedProtoHeader == null || forwardedProtoHeader.isBlank())
? request.getScheme()
: forwardedProtoHeader.split(",")[0].trim();
if (!host.contains(":")) {
String forwardedPort = request.getHeader("X-Forwarded-Port");
if (forwardedPort != null
&& !forwardedPort.isBlank()
&& !isDefaultPort(proto, forwardedPort.trim())) {
host = host + ":" + forwardedPort.trim();
}
}
return Optional.of(proto + "://" + host);
}
private Optional<String> resolveOriginFromReferer(HttpServletRequest request) {
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
URI refererUri = URI.create(referer);
String host = refererUri.getHost();
if (host == null) {
return Optional.empty();
}
String origin = refererUri.getScheme() + "://" + host;
int port = refererUri.getPort();
if (port != -1 && port != 80 && port != 443) {
origin += ":" + port;
}
return Optional.of(origin);
} catch (IllegalArgumentException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
}
return Optional.empty();
}
private String buildOriginFromRequest(HttpServletRequest request) {
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
if ((!"http".equalsIgnoreCase(scheme) || serverPort != 80)
&& (!"https".equalsIgnoreCase(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString();
}
private boolean isDefaultPort(String scheme, String port) {
if (port == null) {
return true;
}
try {
int parsedPort = Integer.parseInt(port);
return ("http".equalsIgnoreCase(scheme) && parsedPort == 80)
|| ("https".equalsIgnoreCase(scheme) && parsedPort == 443);
} catch (NumberFormatException e) {
return false;
}
}
private void clearRedirectCookie(HttpServletResponse response) {
// TODO: Migration required - was org.springframework.http.ResponseCookie with SameSite=Lax.
// jakarta.servlet.Cookie has no SameSite setter on this servlet API level; SameSite=Lax is
// dropped here. Set it via the new SAML SP servlet response or quarkus.http config if
// needed.
Cookie cookie = new Cookie(SPA_REDIRECT_COOKIE, "");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
@@ -399,14 +399,18 @@ public class UserService implements UserServiceInterface {
public void changePassword(User user, String newPassword)
throws SQLException, UnsupportedProviderException {
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.persist(user);
// Spring Data save() upserted; Panache persist() rejects an already-persisted (detached)
// entity ("Detached entity passed to persist"). The user was loaded in the request context,
// so re-attach via merge to update it.
userRepository.getEntityManager().merge(user);
databaseService.exportDatabase();
}
@Transactional
public void changeFirstUse(User user, boolean firstUse)
throws SQLException, UnsupportedProviderException {
user.setFirstLogin(firstUse);
userRepository.persist(user);
userRepository.getEntityManager().merge(user);
databaseService.exportDatabase();
}
@@ -1,289 +0,0 @@
package stirling.software.proprietary.security;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Disabled("TODO: Migration required - Spring Boot test framework not available in Quarkus")
@ExtendWith(MockitoExtension.class)
class CustomLogoutSuccessHandlerTest {
@Mock private ApplicationProperties.Security securityProperties;
@Mock private JwtServiceInterface jwtService;
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@Test
void testSuccessfulLogout() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String token = "token";
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(logoutPath);
}
@Test
void testSuccessfulLogoutViaJWT() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String logoutPath = "/login?logout=true";
String token = "token";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(logoutPath);
}
@Test
void testSuccessfulLogoutViaOAuth2() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken);
verify(response).sendRedirect("http://localhost:8080/login?logout=true");
}
@Test
void testUserIsDisabledRedirect() throws IOException {
String error = "userIsDisabled";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testUserAlreadyExistsWebRedirect() throws IOException {
String error = "oAuth2AuthenticationErrorWeb";
String errorPath = "userAlreadyExistsWeb";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + errorPath);
}
@Test
void testErrorOAuthRedirect() throws IOException {
String error = "testError";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2AutoCreateDisabled() throws IOException {
String error = "oAuth2AutoCreateDisabled";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getContextPath()).thenReturn(url);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2Error() throws IOException {
String error = "test";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2BadCredentialsError() throws IOException {
String error = "badCredentials";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2AdminBlockedUser() throws IOException {
String error = "oAuth2AdminBlockedUser";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
}
@@ -1,49 +0,0 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
@Disabled("TODO: Migration required - Spring Boot test framework not available in Quarkus")
class CustomOAuth2AuthenticationFailureHandlerTest {
@Test
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
CustomOAuth2AuthenticationFailureHandler handler =
new CustomOAuth2AuthenticationFailureHandler();
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
request.setParameter("state", "tauri:abc");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationFailure(
request,
response,
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
assertEquals(
"/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied",
response.getRedirectedUrl());
}
@Test
void redirectsToDefaultCallbackWithoutTauriState() throws Exception {
CustomOAuth2AuthenticationFailureHandler handler =
new CustomOAuth2AuthenticationFailureHandler();
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationFailure(
request,
response,
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl());
}
}
+7 -13
View File
@@ -50,25 +50,19 @@ RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble@sha256:b27ca47660a8fa837e47a8533b9b1a3a430295cf29ca28d91af4fd121572dc29 AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Stage 3: Final runtime image on top of pre-built base
# Stage 2: Final runtime image on top of pre-built base
FROM ${BASE_IMAGE}
ARG VERSION_TAG
WORKDIR /app
# Application layers
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
# Quarkus uber runner-jar (self-contained, all dependencies inside). init-without-ocr.sh launches
# it with `java -jar /app/app.jar`. Replaces the former Spring Boot layered-jar extraction
# (java -Djarmode=tools extract --layers + spring-boot-loader layers), which the Quarkus uber-jar
# does not support.
COPY --link --from=app-build --chown=1000:1000 \
/app/app/core/build/stirling-pdf-*-runner.jar /app/app.jar
COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
+7 -13
View File
@@ -46,25 +46,19 @@ RUN DISABLE_ADDITIONAL_FEATURES=false \
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
--no-daemon
# Stage 2: Extract Spring Boot Layers
FROM eclipse-temurin:25-jre-noble AS jar-extract
WORKDIR /tmp
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
# Stage 3: Final runtime image on top of pre-built base
# Stage 2: Final runtime image on top of pre-built base
FROM ${BASE_IMAGE}
ARG VERSION_TAG
WORKDIR /app
# Application layers
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
# Quarkus uber runner-jar (self-contained, all dependencies inside). init-without-ocr.sh launches
# it via `java -jar /app/app.jar`. Replaces the former Spring Boot layered-jar extraction
# (java -Djarmode=tools extract --layers + spring-boot-loader layers), which the Quarkus uber-jar
# does not support; build/libs/*.jar is only the thin (non-runnable) jar.
COPY --link --from=app-build --chown=1000:1000 \
/app/app/core/build/stirling-pdf-*-runner.jar /app/app.jar
COPY --link --from=app-build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
+4 -2
View File
@@ -110,9 +110,11 @@ COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
COPY --chown=1000:1000 scripts/installFonts.sh /scripts/installFonts.sh
COPY --chown=1000:1000 scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh
# Copy built JARs from build stage
# Copy the Quarkus uber runner-jar (self-contained). build/libs/*.jar is the thin jar (app classes
# only, not runnable); the runnable artifact is build/<name>-runner.jar. init-without-ocr.sh runs
# it via `java -jar /app.jar`.
COPY --from=build --chown=1000:1000 \
/app/app/core/build/libs/*.jar /app.jar
/app/app/core/build/stirling-pdf-*-runner.jar /app.jar
COPY --from=build --chown=1000:1000 \
/app/build/libs/restart-helper.jar /restart-helper.jar
+13 -10
View File
@@ -32,9 +32,8 @@ find_jar() {
[[ -f "$STIRLING_JAR" ]] || fail "STIRLING_JAR='$STIRLING_JAR' not found"
candidate="$STIRLING_JAR"
else
candidate=$(find "$REPO_ROOT/app/core/build/libs" -maxdepth 1 -name 'Stirling-PDF*.jar' -o -name 'stirling-pdf*.jar' 2>/dev/null \
| grep -vE '(-plain|-sources)\.jar$' | head -n 1 || true)
[[ -n "$candidate" ]] || fail "No JAR under app/core/build/libs - run './gradlew :stirling-pdf:bootJar' first"
candidate=$(find "$REPO_ROOT/app/core/build" -maxdepth 1 -name '*-runner.jar' 2>/dev/null | head -n 1 || true)
[[ -n "$candidate" ]] || fail "No *-runner.jar under app/core/build - run './gradlew :stirling-pdf:quarkusBuild' first"
fi
# Resolve to an absolute path: test_fixture pushd's into a temp workdir
# before launching java, so a relative path here would dangle.
@@ -86,13 +85,17 @@ test_fixture() {
# to cwd, and we want to make sure we hit the fixture's configs/ and not
# whatever happens to live at the runner's working directory.
pushd "$workdir" >/dev/null
java -Xmx1g -jar "$jar" \
"--server.port=$port" \
"--spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL" \
"--spring.jpa.show-sql=false" \
"--logging.level.root=WARN" \
"--logging.level.stirling=INFO" \
"--logging.level.org.hibernate.tool.schema=INFO" \
# Quarkus reads config from -D system properties (which must precede -jar),
# not Spring's post-jar --key=value args. Translated 1:1 from the former
# Spring properties; system properties override application.properties.
java -Xmx1g \
"-Dquarkus.http.port=$port" \
"-Dquarkus.datasource.jdbc.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=TRUE;MODE=PostgreSQL" \
"-Dquarkus.hibernate-orm.log.sql=false" \
"-Dquarkus.log.level=WARN" \
"-Dquarkus.log.category.stirling.level=INFO" \
'-Dquarkus.log.category."org.hibernate.tool.schema".level=INFO' \
-jar "$jar" \
> "$log_file" 2>&1 &
local pid=$!
popd >/dev/null
+8 -13
View File
@@ -953,13 +953,12 @@ JAVA_CMD=(
if [ -f "/app.jar" ]; then
JAVA_CMD+=("-jar" "/app.jar")
elif [ -f "/app/app.jar" ]; then
# Spring Boot 4 layered JAR structure (exploded via extract --layers).
# Use -cp (not -jar) so the classpath matches the AOT cache exactly.
JAVA_CMD+=("-cp" "/app/app.jar:/app/lib/*" "stirling.software.SPDF.SPDFApplication")
# Quarkus uber runner-jar (self-contained, all dependencies inside). Launched via -jar;
# the manifest Main-Class bootstraps the Quarkus application (@QuarkusMain SPDFApplication).
JAVA_CMD+=("-jar" "/app/app.jar")
else
# Legacy fallback for Spring Boot 3 layered layout
export JAVA_MAIN_CLASS=org.springframework.boot.loader.launch.JarLauncher
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
log "ERROR: no application jar found at /app.jar or /app/app.jar"
exit 1
fi
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
@@ -1032,15 +1031,11 @@ if [ "$AOT_GENERATE_BACKGROUND" = true ]; then
while [ "$_attempt" -le "$_max_attempts" ]; do
log "AOT: Background cache generation attempt ${_attempt}/${_max_attempts}..."
_gen_rc=0
if [ -f /app/app.jar ] && [ -d /app/lib ]; then
generate_aot_cache "$AOT_CACHE" \
-cp "/app/app.jar:/app/lib/*" stirling.software.SPDF.SPDFApplication || _gen_rc=$?
if [ -f /app/app.jar ]; then
# Quarkus uber runner-jar (self-contained); mirror the runtime -jar launch.
generate_aot_cache "$AOT_CACHE" -jar /app/app.jar || _gen_rc=$?
elif [ -f /app.jar ]; then
generate_aot_cache "$AOT_CACHE" -jar /app.jar || _gen_rc=$?
elif [ -d /app/BOOT-INF ]; then
# Spring Boot exploded layer layout, mirror the exact JAVA_CMD classpath
generate_aot_cache "$AOT_CACHE" \
-cp /app org.springframework.boot.loader.launch.JarLauncher || _gen_rc=$?
else
log "AOT: Cannot determine JAR layout; skipping cache generation."
exit 0