Improved SAML config settings

This commit is contained in:
Dario Ghunney Ware
2026-01-26 11:07:38 +00:00
parent 537aee3ab6
commit b44bb7c50c
8 changed files with 302 additions and 147 deletions
@@ -214,97 +214,182 @@ public class ApplicationProperties {
@Setter
@ToString
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@ToString.Exclude
@JsonProperty("idpMetadataUri")
private String idpMetadataUri;
private String idpSingleLogoutUrl;
private String idpSingleLoginUrl;
@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;
}
@ToString.Exclude private String metadataUri;
@JsonProperty("idpCert")
private Provider provider = new Provider();
private SP sp = new SP();
// Legacy field mappings for backward compatibility
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String idpMetadataUri;
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String idpSingleLogoutUrl;
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String idpSingleLoginUrl;
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String idpIssuer;
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String idpEntityId;
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String idpCert;
@ToString.Exclude
@JsonProperty("privateKey")
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String privateKey;
@ToString.Exclude
@JsonProperty("spCert")
@Deprecated(since = "2.1.5", forRemoval = true)
@JsonIgnore
private String spCert;
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri == null || idpMetadataUri.isBlank()) {
throw new IOException("security.saml2.idpMetadataUri is not configured");
/** Migrate legacy flat properties to new nested structure on set. */
public void setIdpMetadataUri(String value) {
this.idpMetadataUri = value;
if (value != null
&& !value.isBlank()
&& (metadataUri == null || metadataUri.isBlank())) {
this.metadataUri = value;
}
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
}
public void setIdpSingleLoginUrl(String value) {
this.idpSingleLoginUrl = value;
if (value != null && !value.isBlank()) {
this.provider.setSingleLoginUrl(value);
}
}
public void setIdpSingleLogoutUrl(String value) {
this.idpSingleLogoutUrl = value;
if (value != null && !value.isBlank()) {
this.provider.setSingleLogoutUrl(value);
}
}
public void setIdpIssuer(String value) {
this.idpIssuer = value;
if (value != null && !value.isBlank()) {
this.provider.setEntityId(value);
}
}
public void setIdpEntityId(String value) {
this.idpEntityId = value;
if (value != null && !value.isBlank()) {
this.provider.setEntityId(value);
}
}
public void setIdpCert(String value) {
this.idpCert = value;
if (value != null && !value.isBlank()) {
this.provider.setCert(value);
}
}
public void setPrivateKey(String value) {
this.privateKey = value;
if (value != null && !value.isBlank()) {
this.sp.setPrivateKey(value);
}
}
public void setSpCert(String value) {
this.spCert = value;
if (value != null && !value.isBlank()) {
this.sp.setCert(value);
}
}
@JsonIgnore
public InputStream getMetadataUriAsStream() throws IOException {
String uri = getEffectiveMetadataUri();
if (uri == null || uri.isBlank()) {
throw new IOException("security.saml2.metadataUri is not configured");
}
if (uri.startsWith("classpath:")) {
return new ClassPathResource(uri.substring("classpath:".length()))
.getInputStream();
}
try {
URI uri = new URI(idpMetadataUri);
URL url = uri.toURL();
URI parsedUri = new URI(uri);
URL url = parsedUri.toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
return connection.getInputStream();
} catch (URISyntaxException e) {
throw new IOException("Invalid URI format: " + idpMetadataUri, e);
throw new IOException("Invalid URI format: " + uri, e);
}
}
@JsonIgnore
public String getIdpMetadataUriLocation() {
return idpMetadataUri;
public String getEffectiveMetadataUri() {
if (metadataUri != null && !metadataUri.isBlank()) {
return metadataUri;
}
return idpMetadataUri; // Legacy fallback
}
@JsonIgnore
public Resource getSpCert() {
if (spCert == null) return null;
if (spCert.startsWith("classpath:")) {
return new ClassPathResource(spCert.substring("classpath:".length()));
} else {
return new FileSystemResource(spCert);
/** IdP configuration - manual fallback when metadata is unavailable. */
@Data
public static class Provider {
private String name = ""; // Display name only
private String singleLoginUrl;
private String singleLogoutUrl;
private String entityId;
@ToString.Exclude private String cert;
@JsonIgnore
public Resource getCertResource() {
if (cert == null) return null;
if (cert.startsWith("classpath:")) {
return new ClassPathResource(cert.substring("classpath:".length()));
} else {
return new FileSystemResource(cert);
}
}
}
@JsonIgnore
public Resource getIdpCert() {
if (idpCert == null) return null;
if (idpCert.startsWith("classpath:")) {
return new ClassPathResource(idpCert.substring("classpath:".length()));
} else {
return new FileSystemResource(idpCert);
}
}
/** Service Provider (SP) credentials for signing SAML requests. */
@Data
public static class SP {
@ToString.Exclude private String privateKey;
@ToString.Exclude private String cert;
@JsonIgnore
public Resource getPrivateKey() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
@JsonIgnore
public Resource getPrivateKeyResource() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
}
}
@JsonIgnore
public Resource getCertResource() {
if (cert == null) return null;
if (cert.startsWith("classpath:")) {
return new ClassPathResource(cert.substring("classpath:".length()));
} else {
return new FileSystemResource(cert);
}
}
}
}
@@ -167,7 +167,7 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
@@ -47,22 +47,23 @@ security:
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
saml2:
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
provider: "" # The name of your Provider
enableSingleLogout: false # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
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 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
metadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # RECOMMENDED: Your IdP's metadata URI. When provided, IdP config is auto-discovered.
provider: # IdP manual configuration - use if metadataUri is not available
name: "" # Display name for your IdP (optional)
singleLoginUrl: "" # SSO URL
singleLogoutUrl: "" # SLO URL
entityId: '' # IdP Entity ID
cert: classpath:okta.cert # IdP signing certificate
sp: # Service Provider (your app) credentials for signing SAML requests. Generated from your keypair
privateKey: classpath:saml-private-key.key # Your private key.
cert: classpath:saml-public-cert.crt # Your signing certificate.
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
jwt:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
@@ -95,40 +95,38 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private boolean handleSamlLogout(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
if (authentication instanceof Saml2Authentication samlAuthentication) {
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
String nameId = principal.nameId();
if (securityProperties.getSaml2().getEnableSingleLogout()) {
log.info("SP-initiated SLO detected, logging out via IdP");
if (securityProperties.getSaml2().getEnableSingleLogout()) {
log.info("SAML user {} logging out via IdP SLO (session-based)", nameId);
if (authentication instanceof Saml2Authentication samlAuthentication) {
try {
samlLogoutHandler.onLogoutSuccess(request, response, authentication);
samlLogoutHandler.onLogoutSuccess(request, response, samlAuthentication);
} catch (Exception e) {
log.error("SAML SLO failed, falling back to local logout", e);
log.error("SP-initiated SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return true;
} else {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
// Reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(request);
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
} catch (Exception e) {
log.error("SP-initiated SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return true;
}
}
return true;
}
// Reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(request);
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
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);
}
} else {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return true;
}
@@ -53,9 +53,6 @@ public class OAuth2Configuration {
ApplicationProperties applicationProperties, @Lazy UserService userService) {
this.userService = userService;
this.applicationProperties = applicationProperties;
log.info(
"OAuth2Configuration initialized - OAuth2 enabled: {}",
applicationProperties.getSecurity().getOauth2().getEnabled());
}
@Bean
@@ -222,8 +219,6 @@ public class OAuth2Configuration {
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
@@ -44,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Configuration
@@ -59,7 +60,7 @@ public class Saml2Configuration {
@Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
Optional<IdpMetadataInfo> metadataInfo = loadIdpMetadata(samlConf);
@@ -76,8 +77,8 @@ public class Saml2Configuration {
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
Resource privateKeyResource = samlConf.getSp().getPrivateKeyResource();
Resource certificateResource = samlConf.getSp().getCertResource();
log.debug("Loading SP private key from: {}", privateKeyResource.getDescription());
if (!privateKeyResource.exists()) {
@@ -116,19 +117,19 @@ public class Saml2Configuration {
metadataInfo
.map(IdpMetadataInfo::entityId)
.filter(id -> id != null && !id.isBlank())
.orElseGet(samlConf::getIdpEntityIdOrIssuer);
.orElseGet(() -> samlConf.getProvider().getEntityId());
String idpSingleLoginUrl =
metadataInfo
.map(IdpMetadataInfo::singleSignOnServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(samlConf::getIdpSingleLoginUrl);
.orElseGet(() -> samlConf.getProvider().getSingleLoginUrl());
String idpSingleLogoutUrl =
metadataInfo
.map(IdpMetadataInfo::singleLogoutServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(samlConf::getIdpSingleLogoutUrl);
.orElseGet(() -> samlConf.getProvider().getSingleLogoutUrl());
// Validate required IdP configuration
if (idpEntityId == null || idpEntityId.isBlank()) {
@@ -190,7 +191,7 @@ public class Saml2Configuration {
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
idpEntityId);
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@@ -249,7 +250,7 @@ public class Saml2Configuration {
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
try {
Resource idpCertResource = samlConf.getIdpCert();
Resource idpCertResource = samlConf.getProvider().getCertResource();
if (idpCertResource == null) {
throw new IllegalStateException("SAML2 IdP certificate resource is not defined");
}
@@ -274,18 +275,72 @@ public class Saml2Configuration {
log.info(
"Applying IdP metadata overrides for registration: {}",
samlConf.getRegistrationId());
overrideIfPresent(metadataInfo.entityId(), samlConf::setIdpIssuer);
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), samlConf::setIdpSingleLoginUrl);
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), samlConf::setIdpSingleLogoutUrl);
SAML2.Provider provider = samlConf.getProvider();
overrideIfPresent(metadataInfo.entityId(), provider::setEntityId);
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), provider::setSingleLoginUrl);
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), provider::setSingleLogoutUrl);
// Persist discovered metadata values to settings.yml
persistMetadataToSettings(metadataInfo);
}
/**
* Persists IdP metadata discovered values to settings.yml. This ensures the discovered
* configuration is saved for future reference and survives restarts even if the metadata
* endpoint becomes unavailable.
*/
private void persistMetadataToSettings(IdpMetadataInfo metadataInfo) {
log.info(
"Migrating discovered IdP metadata to SAML configuration. Existing configuration will be overridden.");
try {
boolean anyPersisted = false;
if (hasText(metadataInfo.entityId())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.entityId", metadataInfo.entityId());
log.info(" -> Persisted provider.entityId: {}", metadataInfo.entityId());
anyPersisted = true;
}
if (hasText(metadataInfo.singleSignOnServiceUrl())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.singleLoginUrl",
metadataInfo.singleSignOnServiceUrl());
log.info(
" -> Persisted provider.singleLoginUrl: {}",
metadataInfo.singleSignOnServiceUrl());
anyPersisted = true;
}
if (hasText(metadataInfo.singleLogoutServiceUrl())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.singleLogoutUrl",
metadataInfo.singleLogoutServiceUrl());
log.info(
" -> Persisted provider.singleLogoutUrl: {}",
metadataInfo.singleLogoutServiceUrl());
anyPersisted = true;
}
if (anyPersisted) {
log.info(
"IdP metadata successfully persisted to settings.yml. These values will be used as fallback if metadataUri becomes unavailable.");
}
} catch (Exception e) {
log.warn(
"Failed to persist IdP metadata to settings.yml: {}. SAML will still work but discovered values won't be saved.",
e.getMessage());
}
}
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
String metadataLocation = samlConf.getIdpMetadataUriLocation();
String metadataLocation = samlConf.getEffectiveMetadataUri();
if (metadataLocation == null || metadataLocation.isBlank()) {
return Optional.empty();
}
try (InputStream metadataStream = samlConf.getIdpMetadataUri()) {
try (InputStream metadataStream = samlConf.getMetadataUriAsStream()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
@@ -486,12 +486,6 @@ export const SAML2_PROVIDER: Provider = {
description: 'Enable SAML2 authentication (Enterprise only)',
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: 'Provider Name',
description: 'The name of your SAML2 provider',
},
{
key: 'registrationId',
type: 'text',
@@ -500,51 +494,66 @@ export const SAML2_PROVIDER: Provider = {
defaultValue: 'stirling',
},
{
key: 'idpMetadataUri',
key: 'metadataUri',
type: 'text',
label: 'IDP Metadata URI',
description: 'The URI for your provider\'s metadata',
label: 'Metadata URI',
description: 'Your IdP\'s metadata URI (recommended - auto-discovers IdP config)',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
},
{
key: 'idpSingleLoginUrl',
key: 'enableSingleLogout',
type: 'switch',
label: 'Enable Single Logout',
description: 'Enable SP-initiated Single Logout (SLO)',
defaultValue: false,
},
// IdP Provider settings (manual config - only needed if metadataUri not available)
{
key: 'provider.name',
type: 'text',
label: 'IDP Single Login URL',
description: 'The URL for initiating SSO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
label: 'Provider Name',
description: 'Display name for your IdP (optional, for reference only)',
},
{
key: 'idpSingleLogoutUrl',
key: 'provider.singleLoginUrl',
type: 'text',
label: 'IDP Single Logout URL',
description: 'The URL for initiating SLO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
label: 'SSO URL',
description: 'IdP Single Sign-On URL (auto-discovered from metadata if set)',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/.../sso/saml',
},
{
key: 'idpIssuer',
key: 'provider.singleLogoutUrl',
type: 'text',
label: 'IDP Issuer',
description: 'The ID of your provider',
label: 'SLO URL',
description: 'IdP Single Logout URL (auto-discovered from metadata if set)',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/.../slo/saml',
},
{
key: 'idpCert',
key: 'provider.entityId',
type: 'text',
label: 'IDP Certificate',
description: 'The certificate path (e.g., classpath:okta.cert)',
label: 'IdP Entity ID',
description: 'IdP Entity ID (auto-discovered from metadata if set)',
},
{
key: 'provider.cert',
type: 'text',
label: 'IdP Certificate',
description: 'IdP signing certificate path (auto-discovered from metadata if set)',
placeholder: 'classpath:okta.cert',
},
// SP credentials
{
key: 'privateKey',
key: 'sp.privateKey',
type: 'text',
label: 'Private Key',
description: 'Your private key path',
label: 'SP Private Key',
description: 'Your Service Provider private key path',
placeholder: 'classpath:saml-private-key.key',
},
{
key: 'spCert',
key: 'sp.cert',
type: 'text',
label: 'SP Certificate',
description: 'Your signing certificate path',
description: 'Your Service Provider signing certificate path',
placeholder: 'classpath:saml-public-cert.crt',
},
{
@@ -209,9 +209,20 @@ export default function AdminConnectionsSection() {
return !!(providerSettings?.clientId);
};
// Helper to get nested value from object using dot notation
const getNestedValue = (obj: Record<string, any>, path: string): any => {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings?.saml2 || {};
const saml2 = settings?.saml2 || {};
// Flatten nested structure to match field keys with dot notation
const result: Record<string, any> = {};
provider.fields.forEach((field) => {
result[field.key] = getNestedValue(saml2, field.key);
});
return result;
}
if (provider.id === 'smtp') {
@@ -297,8 +308,9 @@ export default function AdminConnectionsSection() {
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
// SAML2 settings
// SAML2 settings - keys may use dot notation (e.g., 'provider.name')
Object.keys(providerSettings).forEach((key) => {
// Key already has dot notation for nested paths, just prepend security.saml2.
deltaSettings[`security.saml2.${key}`] = providerSettings[key];
});
} else if (provider.id === 'oauth2-generic') {