Improved SAML config settings
This commit is contained in:
+145
-60
@@ -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
|
||||
|
||||
+25
-27
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
-5
@@ -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
|
||||
|
||||
+68
-13
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
+14
-2
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user