diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html index b86537d2bfaf..63a534dc3f03 100644 --- a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-saml-config.component.html @@ -144,8 +144,10 @@

tooltipPosition="top">
+
@@ -158,8 +160,10 @@

tooltipPosition="top">
+
diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts index 3713dfa8d4b2..b9b9c18c28c3 100644 --- a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.spec.ts @@ -1,4 +1,9 @@ -import { DotAuthConfig, DotAuthConfigView, DotAuthDiscoveryView } from '@dotcms/dotcms-models'; +import { + DotAuthConfig, + DotAuthConfigView, + DotAuthDiscoveryView, + DotAuthSignatureValidation +} from '@dotcms/dotcms-models'; import { DEFAULT_CONFIG, @@ -323,6 +328,39 @@ describe('dot-auth-config.mappers', () => { expect(config.saml.wantResponseSigned).toBe(true); }); + it('maps single-signature validation types', () => { + const assertionOnly = fromView({ + ...SAML_VIEW, + values: { ...SAML_VIEW.values, signatureValidationType: 'assertion' } + }); + expect(assertionOnly.saml.wantAssertionsSigned).toBe(true); + expect(assertionOnly.saml.wantResponseSigned).toBe(false); + + const responseOnly = fromView({ + ...SAML_VIEW, + values: { ...SAML_VIEW.values, signatureValidationType: 'response' } + }); + expect(responseOnly.saml.wantAssertionsSigned).toBe(false); + expect(responseOnly.saml.wantResponseSigned).toBe(true); + }); + + it('shows a legacy "none" or unknown validation type as both signatures required', () => { + // out-of-range values on purpose: what an old or hand-edited configuration may hold + const legacyValues = [ + 'none', + '', + 'signature' + ] as unknown as DotAuthSignatureValidation[]; + for (const signatureValidationType of legacyValues) { + const config = fromView({ + ...SAML_VIEW, + values: { ...SAML_VIEW.values, signatureValidationType } + }); + expect(config.saml.wantAssertionsSigned).toBe(true); + expect(config.saml.wantResponseSigned).toBe(true); + } + }); + it('maps SAML claim fields', () => { const config = fromView(SAML_VIEW); expect(config.saml.claimEmail).toBe('mail'); @@ -527,13 +565,14 @@ describe('dot-auth-config.mappers', () => { ] ).toBe('response'); + // never 'none': with neither box checked both signatures are required config.saml.wantAssertionsSigned = false; config.saml.wantResponseSigned = false; expect( (toPayload(config, 'test-site-id').values as Record)[ 'signatureValidationType' ] - ).toBe('none'); + ).toBe('responseandassertion'); }); it('round-trips idpName and sPEndpointHostname so a save never wipes a working SP hostname', () => { diff --git a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts index f06ba2824c39..b1f838e912a8 100644 --- a/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts +++ b/core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/store/dot-auth-config.mappers.ts @@ -80,6 +80,17 @@ export const DEFAULT_CONFIG: DotAuthConfig = { } }; +/** The SAML signature validation type the backend enforces for a stored value. */ +function samlValidationType(value: unknown): 'response' | 'assertion' | 'responseandassertion' { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + + return normalized === 'response' || normalized === 'assertion' + ? normalized + : 'responseandassertion'; +} + export function fromView(view: DotAuthConfigView): DotAuthConfig { const config = clone(DEFAULT_CONFIG); config.protocol = @@ -112,12 +123,10 @@ export function fromView(view: DotAuthConfigView): DotAuthConfig { x509cert: String(values.publicCert ?? ''), privateKey: String(values.privateKey ?? ''), signRequests: String(values['signRequests'] ?? 'true') === 'true', - wantAssertionsSigned: - values.signatureValidationType === 'assertion' || - values.signatureValidationType === 'responseandassertion', - wantResponseSigned: - values.signatureValidationType === 'response' || - values.signatureValidationType === 'responseandassertion', + // Anything other than 'response' / 'assertion' (e.g. a legacy 'none') is enforced by + // the backend as 'responseandassertion', so show it that way. + wantAssertionsSigned: samlValidationType(values.signatureValidationType) !== 'response', + wantResponseSigned: samlValidationType(values.signatureValidationType) !== 'assertion', claimEmail: String(values['attribute.email.name'] ?? config.saml.claimEmail), claimFirstName: String( values['attribute.firstname.name'] ?? config.saml.claimFirstName @@ -180,13 +189,13 @@ export function toPayload(config: DotAuthConfig, siteId: string): DotAuthConfigP // empty string would be STORED and shadow the host-name fallback downstream. sPEndpointHostname: config.saml.spEndpointHostname || undefined, signRequests: String(config.saml.signRequests), - signatureValidationType: config.saml.wantResponseSigned - ? config.saml.wantAssertionsSigned - ? 'responseandassertion' - : 'response' - : config.saml.wantAssertionsSigned - ? 'assertion' - : 'none', + // Never 'none': with neither box checked both signatures are required. + signatureValidationType: + config.saml.wantResponseSigned && !config.saml.wantAssertionsSigned + ? 'response' + : config.saml.wantAssertionsSigned && !config.saml.wantResponseSigned + ? 'assertion' + : 'responseandassertion', idPMetadataFile: config.saml.metadataUrl, publicCert: config.saml.x509cert || undefined, privateKey: config.saml.privateKey || undefined, diff --git a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java index dbc57e47960f..73db4b1076ab 100644 --- a/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java +++ b/dotCMS/src/main/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandler.java @@ -2,6 +2,7 @@ import com.dotcms.auth.dotAuth.DotAuthConstants; import com.dotcms.auth.dotAuth.rest.DotAuthProtocol; +import com.dotcms.saml.DotIdentityProviderConfigurationImpl; import com.dotcms.saml.DotSamlProxyFactory; import com.dotcms.security.apps.AppSecrets; import com.dotmarketing.util.UtilMethods; @@ -9,6 +10,7 @@ import io.vavr.control.Try; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -40,6 +42,8 @@ public final class SamlProtocolHandler implements ProtocolHandler { private static final Set BOOLEAN_KEYS = Set.of("enable"); + private static final String SIGNATURE_VALIDATION_TYPE_KEY = "signatureValidationType"; + /** * Extra-key names must be identifier-shaped. Bars whitespace tricks like * {@code "privateKey "} (trailing space) that would otherwise slip past the @@ -117,6 +121,8 @@ public AppSecrets buildSecrets(final Map incoming, if (raw == null) continue; if (BOOLEAN_KEYS.contains(key)) { builder.withSecret(key, Boolean.parseBoolean(String.valueOf(raw))); + } else if (SIGNATURE_VALIDATION_TYPE_KEY.equals(key)) { + builder.withSecret(key, validSignatureValidationType(String.valueOf(raw))); } else { builder.withSecret(key, String.valueOf(raw)); if ("publicCert".equals(key) && UtilMethods.isSet(String.valueOf(raw))) { @@ -175,4 +181,17 @@ public AppSecrets buildSecrets(final Map incoming, } return builder.build(); } + + /** + * Only "response", "assertion" and "responseandassertion" can be stored: SAML responses + * are never accepted without an IdP signature. + */ + private static String validSignatureValidationType(final String value) { + final String normalized = value.trim().toLowerCase(Locale.ROOT); + if (!normalized.equals(DotIdentityProviderConfigurationImpl.resolveSignatureValidationType(normalized))) { + throw new BadRequestException(SIGNATURE_VALIDATION_TYPE_KEY + + " must be one of response, assertion or responseandassertion"); + } + return normalized; + } } diff --git a/dotCMS/src/main/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImpl.java b/dotCMS/src/main/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImpl.java index d6d237e52d43..82081349f1a4 100644 --- a/dotCMS/src/main/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImpl.java +++ b/dotCMS/src/main/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImpl.java @@ -9,6 +9,7 @@ import java.nio.file.Files; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; @@ -23,6 +24,8 @@ public abstract class DotAbstractSamlConfigurationServiceImpl implements SamlCon private static final String NULL = "NULL"; private static final String UNABLE_TO_READ_FILE = "File does not exist or unable to read : "; private static final String NOT_FOUND_ERROR = "Property Name not Found: "; + private static final Set SIGNATURE_CHECKS = Set.of( + SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS, SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE); private AtomicBoolean init = new AtomicBoolean(false); private final Map defaultProperties = new ConcurrentHashMap<>(); @@ -73,7 +76,17 @@ private synchronized void internalInit(final Map contextMap) { properties.forEach((key, value) -> { final SamlName samlName = SamlName.findProperty((String)key); - if (null != samlName) { + if (null == samlName) { + + return; + } + + if (SIGNATURE_CHECKS.contains(samlName)) { + + // signature checks can't be switched off: keep the default (true) + Logger.warn(this, "Ignoring '" + key + "' in " + dotSamlDefaultPropertiesValue + + ": SAML signatures are always verified."); + } else { this.defaultProperties.put(samlName.getPropertyName(), (String)value); } diff --git a/dotCMS/src/main/java/com/dotcms/saml/DotIdentityProviderConfigurationImpl.java b/dotCMS/src/main/java/com/dotcms/saml/DotIdentityProviderConfigurationImpl.java index 50915a6a511b..88e3ca18261f 100644 --- a/dotCMS/src/main/java/com/dotcms/saml/DotIdentityProviderConfigurationImpl.java +++ b/dotCMS/src/main/java/com/dotcms/saml/DotIdentityProviderConfigurationImpl.java @@ -7,8 +7,12 @@ import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.util.Logger; +import java.util.Locale; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * Default implementation to retrieve the configuration from apps @@ -16,17 +20,82 @@ */ public class DotIdentityProviderConfigurationImpl implements IdentityProviderConfiguration { + private static final Set SIGNATURE_VALIDATION_TYPES = Set.of( + DotSamlConstants.RESPONSE, DotSamlConstants.ASSERTION, DotSamlConstants.RESPONSE_AND_ASSERTION); + + /** + * Extra parameters that used to switch off parts of the signature verification. Stored values are + * ignored, so the SAML bundle always uses its default (true). + */ + private static final Set IGNORED_PROPERTIES = Set.of( + SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS.getPropertyName(), + SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE.getPropertyName()); + + /** + * Warnings already logged, keyed by site and setting. A configuration is built for every request + * that needs it, so each warning is logged once per site and value rather than on every request. + */ + private static final Set LOGGED_WARNINGS = ConcurrentHashMap.newKeySet(); + private final AppsAPI appsAPI; private final Host host; private final AppSecrets appSecrets; public DotIdentityProviderConfigurationImpl(final AppsAPI appsAPI, final Host host) throws DotSecurityException, DotDataException { + this(appsAPI, host, appsAPI.getSecrets(DotSamlProxyFactory.SAML_APP_CONFIG_KEY, + true, host, APILocator.systemUser()).get()); + } + + DotIdentityProviderConfigurationImpl(final AppsAPI appsAPI, final Host host, final AppSecrets appSecrets) { + this.appsAPI = appsAPI; this.host = host; - this.appSecrets = this.appsAPI.getSecrets(DotSamlProxyFactory.SAML_APP_CONFIG_KEY, - true, host, APILocator.systemUser()).get(); + this.appSecrets = appSecrets; + + this.warnAboutOverriddenSettings(); + } + + /** + * Returns the signature validation type when it is "response", "assertion" or "responseandassertion" + * (ignoring case and surrounding blanks). Anything else, including blank and "none", fails closed to + * "responseandassertion": signatures can not be switched off through this setting. + * + * @param configured the stored value, may be null + * @return one of the supported validation types + */ + public static String resolveSignatureValidationType(final String configured) { + + final String normalized = null == configured ? "" : configured.trim().toLowerCase(Locale.ROOT); + return SIGNATURE_VALIDATION_TYPES.contains(normalized) ? normalized : DotSamlConstants.RESPONSE_AND_ASSERTION; + } + private void warnAboutOverriddenSettings() { + + final Optional validationType = + this.findSecret(SamlName.DOT_SAML_SIGNATURE_VALIDATION_TYPE.getPropertyName()); + final String configured = validationType.map(Secret::getString).orElse(null); + if ((null == configured || !SIGNATURE_VALIDATION_TYPES.contains(configured.trim().toLowerCase(Locale.ROOT))) + && firstTime("validationType=" + configured)) { + + Logger.warn(this, "SAML configuration for site '" + this.host.getHostname() + "' has an unsupported " + + "signature validation type '" + configured + "'. A signed response and a signed assertion " + + "are required until a supported Validation Type is saved."); + } + + for (final String ignoredProperty : IGNORED_PROPERTIES) { + + if (this.findSecret(ignoredProperty).isPresent() && firstTime(ignoredProperty)) { + + Logger.warn(this, "SAML configuration for site '" + this.host.getHostname() + "' sets '" + + ignoredProperty + "', which is ignored: SAML signatures are always verified."); + } + } + } + + private boolean firstTime(final String warning) { + + return LOGGED_WARNINGS.add(this.host.getIdentifier() + '|' + warning); } private Optional findSecret (final String key) { @@ -78,7 +147,7 @@ public String getSignatureValidationType() { final String signatureValidationTypeKey = SamlName.DOT_SAML_SIGNATURE_VALIDATION_TYPE.getPropertyName(); final Optional secretOpt = this.findSecret(signatureValidationTypeKey); - return secretOpt.isPresent()? secretOpt.get().getString(): null; + return resolveSignatureValidationType(secretOpt.isPresent()? secretOpt.get().getString(): null); } @Override @@ -111,6 +180,10 @@ public char[] getPrivateKey() { @Override public Object getOptionalProperty(final String propertyKey) { + if (IGNORED_PROPERTIES.contains(propertyKey)) { + return null; + } + final Optional secretOpt = this.findSecret(propertyKey); return secretOpt.isPresent()? secretOpt.get().getString(): null; } @@ -119,7 +192,7 @@ public Object getOptionalProperty(final String propertyKey) { @Override public boolean containsOptionalProperty(final String propertyKey) { - return this.findSecret(propertyKey).isPresent(); + return !IGNORED_PROPERTIES.contains(propertyKey) && this.findSecret(propertyKey).isPresent(); } @Override diff --git a/dotCMS/src/main/resources/apps/dotsaml-config.yml b/dotCMS/src/main/resources/apps/dotsaml-config.yml index a784c828dd16..af92a23ec578 100644 --- a/dotCMS/src/main/resources/apps/dotsaml-config.yml +++ b/dotCMS/src/main/resources/apps/dotsaml-config.yml @@ -37,13 +37,9 @@ params: hidden: false type: "SELECT" label: "Validation Type" - hint: "Depends on your IDP Configuration." + hint: "Select exactly the parts of the SAML Response your IDP signs. dotCMS rejects a response unless the selected parts carry a valid IDP signature." required: true value: - - - label: "None" - value: "none" - selected: true - label: "Only Response" value: "response" @@ -53,6 +49,7 @@ params: - label: "Response & Assertion" value: "responseandassertion" + selected: true idPMetadataFile: value: "" hidden: false diff --git a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java index 15627034b2b9..769f526cfe71 100644 --- a/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java +++ b/dotCMS/src/test/java/com/dotcms/auth/dotAuth/rest/handler/SamlProtocolHandlerTest.java @@ -211,4 +211,35 @@ public void buildSecrets_omits_previously_stored_undeclared_keys_not_resent() { assertFalse(result.getSecrets().containsKey("emailAttribute")); } + + @Test + public void buildSecrets_rejects_signature_validation_type_none() { + assertThrows(javax.ws.rs.BadRequestException.class, () -> handler.buildSecrets( + Map.of("privateKey", "****", "publicCert", "stored-CERT", "signatureValidationType", "none"), + Optional.of(existingKeypair()))); + } + + @Test + public void buildSecrets_rejects_unknown_signature_validation_type() { + assertThrows(javax.ws.rs.BadRequestException.class, () -> handler.buildSecrets( + Map.of("privateKey", "****", "publicCert", "stored-CERT", "signatureValidationType", "signature"), + Optional.of(existingKeypair()))); + } + + @Test + public void buildSecrets_stores_supported_signature_validation_type_normalized() { + final AppSecrets result = handler.buildSecrets( + Map.of("privateKey", "****", "publicCert", "stored-CERT", "signatureValidationType", " Assertion "), + Optional.of(existingKeypair())); + + assertEquals("assertion", result.getSecrets().get("signatureValidationType").getString()); + } + + private static AppSecrets existingKeypair() { + return AppSecrets.builder() + .withKey(DotSamlProxyFactory.SAML_APP_CONFIG_KEY) + .withHiddenSecret("privateKey", "stored-PEM") + .withSecret("publicCert", "stored-CERT") + .build(); + } } diff --git a/dotCMS/src/test/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImplTest.java b/dotCMS/src/test/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImplTest.java new file mode 100644 index 000000000000..b5d172538ee6 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/saml/DotAbstractSamlConfigurationServiceImplTest.java @@ -0,0 +1,48 @@ +package com.dotcms.saml; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DotAbstractSamlConfigurationServiceImplTest { + + @Test + public void defaults_file_can_not_turn_signature_checks_off(@TempDir final Path tempDir) throws IOException { + final Path defaults = tempDir.resolve("dotcms-saml-default.properties"); + Files.write(defaults, String.join("\n", + SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS.getPropertyName() + "=false", + SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE.getPropertyName() + "=false", + SamlName.DOT_SAML_CLOCK_SKEW.getPropertyName() + "=5000", + "not.a.saml.property=whatever").getBytes(StandardCharsets.UTF_8)); + + final TestConfigurationService service = new TestConfigurationService(); + service.initService(Map.of(SamlConfigurationService.DOT_SAML_DEFAULT_PROPERTIES_CONTEXT_MAP_KEY, + defaults.toString())); + + assertTrue(service.getDefaultBooleanParameter(SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS)); + assertTrue(service.getDefaultBooleanParameter(SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE)); + // other defaults from the file still apply + assertEquals("5000", service.getDefaultStringParameter(SamlName.DOT_SAML_CLOCK_SKEW)); + } + + private static final class TestConfigurationService extends DotAbstractSamlConfigurationServiceImpl { + + @Override + public Map createInitialMap() { + final Map map = new HashMap<>(); + map.put(SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS.getPropertyName(), "true"); + map.put(SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE.getPropertyName(), "true"); + map.put(SamlName.DOT_SAML_CLOCK_SKEW.getPropertyName(), "10000"); + return map; + } + } +} diff --git a/dotCMS/src/test/java/com/dotcms/saml/DotIdentityProviderConfigurationImplTest.java b/dotCMS/src/test/java/com/dotcms/saml/DotIdentityProviderConfigurationImplTest.java new file mode 100644 index 000000000000..015e6692a97c --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/saml/DotIdentityProviderConfigurationImplTest.java @@ -0,0 +1,107 @@ +package com.dotcms.saml; + +import com.dotcms.security.apps.AppSecrets; +import com.dotmarketing.beans.Host; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class DotIdentityProviderConfigurationImplTest { + + @Test + public void supported_signature_validation_types_are_kept() { + assertEquals(DotSamlConstants.RESPONSE, + DotIdentityProviderConfigurationImpl.resolveSignatureValidationType("response")); + assertEquals(DotSamlConstants.ASSERTION, + DotIdentityProviderConfigurationImpl.resolveSignatureValidationType(" Assertion ")); + assertEquals(DotSamlConstants.RESPONSE_AND_ASSERTION, + DotIdentityProviderConfigurationImpl.resolveSignatureValidationType("responseandassertion")); + } + + @Test + public void unsupported_signature_validation_types_require_both_signatures() { + for (final String configured : new String[]{"none", "NONE", "", " ", null, "signature"}) { + assertEquals(DotSamlConstants.RESPONSE_AND_ASSERTION, + DotIdentityProviderConfigurationImpl.resolveSignatureValidationType(configured), + "type '" + configured + "'"); + } + } + + @Test + public void stored_none_is_read_as_response_and_assertion() { + final DotIdentityProviderConfigurationImpl config = config(secrets() + .withSecret("signatureValidationType", "none") + .build()); + + assertEquals(DotSamlConstants.RESPONSE_AND_ASSERTION, config.getSignatureValidationType()); + } + + @Test + public void signature_check_overrides_are_hidden_from_the_bundle() { + final DotIdentityProviderConfigurationImpl config = config(secrets() + .withSecret("signatureValidationType", "assertion") + .withSecret(SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS.getPropertyName(), "false") + .withSecret(SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE.getPropertyName(), "false") + .build()); + + for (final SamlName ignored : new SamlName[]{SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS, + SamlName.DOT_SAML_VERIFY_SIGNATURE_PROFILE}) { + assertFalse(config.containsOptionalProperty(ignored.getPropertyName()), ignored.getPropertyName()); + assertNull(config.getOptionalProperty(ignored.getPropertyName()), ignored.getPropertyName()); + } + } + + @Test + public void other_extra_parameters_still_reach_the_bundle() { + final DotIdentityProviderConfigurationImpl config = config(secrets() + .withSecret("signatureValidationType", "assertion") + .withSecret("allow.unsolicited.responses", "true") + .withSecret(SamlName.DOT_SAML_CLOCK_SKEW.getPropertyName(), "5000") + .build()); + + assertTrue(config.containsOptionalProperty("allow.unsolicited.responses")); + assertEquals("true", config.getOptionalProperty("allow.unsolicited.responses")); + assertTrue(config.containsOptionalProperty(SamlName.DOT_SAML_CLOCK_SKEW.getPropertyName())); + assertEquals("5000", config.getOptionalProperty(SamlName.DOT_SAML_CLOCK_SKEW.getPropertyName())); + assertFalse(config.containsOptionalProperty("not.stored")); + } + + @Test + public void building_the_configuration_repeatedly_still_works_for_legacy_values() { + // the configuration is rebuilt per request; the warning is logged once, the behaviour never changes + final Host host = host(); + final AppSecrets legacy = secrets() + .withSecret("signatureValidationType", "none") + .withSecret(SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS.getPropertyName(), "false") + .build(); + + for (int i = 0; i < 3; i++) { + final DotIdentityProviderConfigurationImpl config = new DotIdentityProviderConfigurationImpl(null, host, legacy); + assertEquals(DotSamlConstants.RESPONSE_AND_ASSERTION, config.getSignatureValidationType()); + assertFalse(config.containsOptionalProperty( + SamlName.DOT_SAML_VERIFY_SIGNATURE_CREDENTIALS.getPropertyName())); + } + } + + private static AppSecrets.Builder secrets() { + return AppSecrets.builder().withKey(DotSamlProxyFactory.SAML_APP_CONFIG_KEY); + } + + private static DotIdentityProviderConfigurationImpl config(final AppSecrets appSecrets) { + return new DotIdentityProviderConfigurationImpl(null, host(), appSecrets); + } + + private static Host host() { + final Host host = mock(Host.class); + when(host.getIdentifier()).thenReturn(UUID.randomUUID().toString()); + when(host.getHostname()).thenReturn("demo.example.com"); + return host; + } +}