Streamlined config with metadata uri

This commit is contained in:
Dario Ghunney Ware
2026-01-24 12:43:19 +00:00
committed by DarioGii
parent a75af13304
commit 08b1f15677
8 changed files with 156 additions and 57 deletions
@@ -226,9 +226,22 @@ public class ApplicationProperties {
private String idpSingleLogoutUrl;
private String idpSingleLoginUrl;
private String idpIssuer;
@Deprecated(since = "2.2.1", forRemoval = true)
private String idpIssuer; // Legacy field name, use idpEntityId instead
private String idpEntityId; // IdP Entity ID (preferred field name)
private Boolean enableSingleLogout = false;
/**
* Gets the IdP Entity ID, checking both idpEntityId (preferred) and idpIssuer (legacy).
*/
@JsonIgnore
public String getIdpEntityIdOrIssuer() {
if (idpEntityId != null && !idpEntityId.isBlank()) {
return idpEntityId;
}
return idpIssuer;
}
@JsonProperty("idpCert")
private String idpCert;
@@ -55,7 +55,8 @@ security:
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your IdP
enableSingleLogout: true # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your IdP
idpIssuer: "" # The ID of your IdP
idpIssuer: "" # The entity ID of your IdP (Deprecated. Use idpEntityId instead)
idpEntityId: '' # The entity ID of your IdP
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your IdP
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
@@ -1,12 +1,9 @@
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.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
@@ -23,7 +20,6 @@ import jakarta.servlet.http.HttpServletResponse;
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.oauth2.KeycloakProvider;
@@ -41,24 +37,19 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
private final LogoutSuccessHandler samlLogoutHandler;
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
AppConfig appConfig,
JwtServiceInterface jwtService) {
this(securityProperties, appConfig, jwtService, null);
ApplicationProperties.Security securityProperties, JwtServiceInterface jwtService) {
this(securityProperties, jwtService, null);
}
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
AppConfig appConfig,
JwtServiceInterface jwtService,
LogoutSuccessHandler samlLogoutHandler) {
this.securityProperties = securityProperties;
this.appConfig = appConfig;
this.jwtService = jwtService;
this.samlLogoutHandler = samlLogoutHandler;
}
@@ -124,23 +115,20 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
return true;
}
// Try to reconstruct Saml2Authentication from JWT claims for SLO
// Reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(request);
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuth.getPrincipal();
log.info(
"SAML user {} logging out via IdP SLO (reconstructed from JWT)",
principal.nameId());
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
} catch (Exception e) {
log.error("SAML SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return true;
}
@@ -182,7 +170,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
return Optional.empty();
}
// Parse session indexes (stored as List in JWT)
List<String> sessionIndexes = Collections.emptyList();
if (sessionIndexesObj instanceof List<?>) {
sessionIndexes =
@@ -214,7 +201,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
return Optional.of(samlAuth);
} catch (Exception ex) {
log.debug("Unable to reconstruct Saml2Authentication from JWT", ex);
log.error("Unable to reconstruct Saml2Authentication from JWT", ex);
return Optional.empty();
}
}
@@ -8,11 +8,13 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
@@ -30,14 +32,13 @@ import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
@@ -60,7 +61,6 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
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.session.SessionPersistentRegistry;
@Slf4j
@Configuration
@@ -76,12 +76,10 @@ public class SecurityConfiguration {
private final ApplicationProperties applicationProperties;
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final JwtServiceInterface jwtService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
@@ -96,14 +94,12 @@ public class SecurityConfiguration {
@Lazy UserService userService,
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
ApplicationProperties.Security securityProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@@ -116,14 +112,12 @@ public class SecurityConfiguration {
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.applicationProperties = applicationProperties;
this.securityProperties = securityProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
@@ -200,7 +194,7 @@ public class SecurityConfiguration {
http.cors(cors -> cors.configurationSource(corsSource));
} else {
// Explicitly disable CORS when no origins are configured
http.cors(cors -> cors.disable());
http.cors(AbstractHttpConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
@@ -248,11 +242,11 @@ public class SecurityConfiguration {
logout ->
// Require POST to prevent logout CSRF attacks
logout.logoutRequestMatcher(
new AntPathRequestMatcher("/logout", "POST"))
PathPatternRequestMatcher.withDefaults()
.matcher(HttpMethod.POST, "/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties,
appConfig,
jwtService,
samlLogoutHandler))
.clearAuthentication(true)
@@ -391,17 +385,12 @@ public class SecurityConfiguration {
// - IdP-initiated logout: IdP sends LogoutRequest to /logout/saml2/slo
// - SP-initiated logout response: IdP sends LogoutResponse to /logout/saml2/slo
if (Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())) {
log.info("SAML2 Single Logout (SLO) is enabled - configuring SLO endpoints");
http.saml2Logout(
logout ->
logout.logoutUrl(
"/logout/saml2/slo") // URL that handles both request
// and response
);
log.debug("SAML2 Single Logout (SLO) is enabled");
http.saml2Logout(logout -> logout.logoutUrl("/logout/saml2/slo"));
}
}
} else {
log.debug("Login is not enabled.");
log.info("Login is not enabled.");
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
}
return http.build();
@@ -0,0 +1,64 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Filter that redirects browser requests to /login to the frontend URL when configured. This is
* needed for development mode where frontend runs on a different port, and for SAML logout which
* redirects to /login?logout after SLO completes.
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequiredArgsConstructor
public class LoginRedirectFilter extends OncePerRequestFilter {
private final ApplicationProperties applicationProperties;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
// Only process /login requests when frontendUrl is configured
if (path.equals("/login") && frontendUrl != null && !frontendUrl.isBlank()) {
// Check if this is a browser request (Accept: text/html) vs API request
// (Accept: application/json)
String acceptHeader = request.getHeader("Accept");
boolean isBrowserRequest =
acceptHeader != null
&& acceptHeader.contains("text/html")
&& !acceptHeader.contains("application/json");
if (isBrowserRequest) {
// Preserve query parameters (e.g., ?logout=true, ?error=xxx)
String queryString = request.getQueryString();
String redirectUrl =
frontendUrl + "/login" + (queryString != null ? "?" + queryString : "");
log.debug("Redirecting browser request to frontend: {}", redirectUrl);
response.sendRedirect(redirectUrl);
return;
}
}
filterChain.doFilter(request, response);
}
}
@@ -108,10 +108,43 @@ public class Saml2Configuration {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Apply metadata overrides - metadata takes precedence over manual config
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
String idpIssuer = samlConf.getIdpIssuer();
String idpSingleLoginUrl = samlConf.getIdpSingleLoginUrl();
String idpSingleLogoutUrl = samlConf.getIdpSingleLogoutUrl();
// Get IdP configuration - prefer values from metadata, fall back to manual config
String idpEntityId =
metadataInfo
.map(IdpMetadataInfo::entityId)
.filter(id -> id != null && !id.isBlank())
.orElseGet(samlConf::getIdpEntityIdOrIssuer);
String idpSingleLoginUrl =
metadataInfo
.map(IdpMetadataInfo::singleSignOnServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(samlConf::getIdpSingleLoginUrl);
String idpSingleLogoutUrl =
metadataInfo
.map(IdpMetadataInfo::singleLogoutServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(samlConf::getIdpSingleLogoutUrl);
// Validate required IdP configuration
if (idpEntityId == null || idpEntityId.isBlank()) {
throw new IllegalStateException(
"SAML2 IdP Entity ID is required. Set security.saml2.idpEntityId or provide idpMetadataUri");
}
if (idpSingleLoginUrl == null || idpSingleLoginUrl.isBlank()) {
throw new IllegalStateException(
"SAML2 IdP Single Sign-On URL is required. Set security.saml2.idpSingleLoginUrl or provide idpMetadataUri");
}
log.info(
"SAML2 IdP configuration: entityId={}, ssoUrl={}, sloUrl={}",
idpEntityId,
idpSingleLoginUrl,
idpSingleLogoutUrl);
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
@@ -142,7 +175,7 @@ public class Saml2Configuration {
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
metadata.entityId(idpIssuer)
metadata.entityId(idpEntityId)
.verificationX509Credentials(
c -> c.add(verificationCredential))
.singleSignOnServiceBinding(
@@ -31,8 +31,7 @@ class CustomLogoutSuccessHandlerTest {
@BeforeEach
void setUp() {
customLogoutSuccessHandler =
new CustomLogoutSuccessHandler(securityProperties, appConfig, jwtService);
customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService);
}
@Test
@@ -142,7 +142,14 @@
.oauth-container-vertical {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem; /* 12px */
width: 100%;
}
.oauth-container-vertical > div {
width: 100%;
max-width: 320rem; /* 320px - consistent width for all buttons */
}
.oauth-button-icon {
@@ -185,22 +192,24 @@
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0.75rem 1rem; /* 12px 16px */
border: 1px solid #d1d5db;
justify-content: center; /* Center icon and text */
padding: 0.875rem 1.25rem !important; /* 14px 20px - increased for better visibility */
min-height: 3rem !important; /* 48px - ensure readable height */
height: auto !important; /* Override Mantine default height */
border: 1px solid #d1d5db !important;
border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only);
background-color: var(--auth-card-bg-light-only) !important;
font-size: 1rem; /* 16px */
font-weight: 500;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary-light-only) !important;
cursor: pointer;
gap: 0.75rem; /* 12px */
gap: 1rem !important; /* 16px - increased spacing between icon and text */
font-family: inherit;
transition: background-color 0.2s ease;
}
.oauth-button-vertical:hover:not(:disabled) {
background-color: #f3f4f6;
background-color: #f3f4f6 !important;
}
.oauth-button-vertical:disabled {
@@ -243,12 +252,16 @@
}
.oauth-icon-tiny {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
width: 1.5rem; /* 24px - increased for better visibility */
height: 1.5rem; /* 24px */
display: block;
flex-shrink: 0;
}
.oauth-button-vertical span {
margin-left: 0.5rem; /* 8px - additional spacing between icon and text */
}
/* Login Header Styles */
.login-header {
margin-bottom: 1rem; /* 16px */