diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java index 1cfa860c1b6..14e36de00ee 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepository.java @@ -1,16 +1,17 @@ package org.cloudfoundry.identity.uaa.provider.saml; import lombok.extern.slf4j.Slf4j; +import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.provider.SamlIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.util.KeyWithCert; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.ZoneAware; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.util.Assert; import java.util.List; +import java.util.Optional; @Slf4j public class ConfiguratorRelyingPartyRegistrationRepository @@ -20,13 +21,17 @@ public class ConfiguratorRelyingPartyRegistrationRepository private final KeyWithCert keyWithCert; private final String samlEntityID; - public ConfiguratorRelyingPartyRegistrationRepository(@Qualifier("samlEntityID") String samlEntityID, + private final String samlEntityIDAlias; + + public ConfiguratorRelyingPartyRegistrationRepository(String samlEntityID, + String samlEntityIDAlias, KeyWithCert keyWithCert, SamlIdentityProviderConfigurator configurator) { Assert.notNull(configurator, "configurator cannot be null"); this.configurator = configurator; this.keyWithCert = keyWithCert; this.samlEntityID = samlEntityID; + this.samlEntityIDAlias = samlEntityIDAlias; } /** @@ -38,39 +43,49 @@ public ConfiguratorRelyingPartyRegistrationRepository(@Qualifier("samlEntityID") */ @Override public RelyingPartyRegistration findByRegistrationId(String registrationId) { + IdentityZone currentZone = retrieveZone(); + String currentZoneId = Optional.ofNullable(currentZone.getId()).orElse(OriginKeys.UAA); + + // TODO: possibly call getIdentityProviderDefinitionsForZone(currentZone), and don't have to check zoneId in the if statement List identityProviderDefinitions = configurator.getIdentityProviderDefinitions(); for (SamlIdentityProviderDefinition identityProviderDefinition : identityProviderDefinitions) { - if (identityProviderDefinition.getIdpEntityAlias().equals(registrationId)) { + if (identityProviderDefinition.getIdpEntityAlias().equals(registrationId) && currentZoneId.equals(identityProviderDefinition.getZoneId())) { + + String zonedSamlEntityID = getZoneEntityId(currentZone); + String zonedSamlEntityAlias = getZoneEntityAlias(currentZone); + boolean requestSigned = currentZone.getConfig().getSamlConfig().isRequestSigned(); - IdentityZone zone = retrieveZone(); return RelyingPartyRegistrationBuilder.buildRelyingPartyRegistration( - samlEntityID, identityProviderDefinition.getNameID(), + zonedSamlEntityID, identityProviderDefinition.getNameID(), keyWithCert, identityProviderDefinition.getMetaDataLocation(), - registrationId, zone.getConfig().getSamlConfig().isRequestSigned()); + registrationId, zonedSamlEntityAlias, requestSigned); } } - return buildDefaultRelyingPartyRegistration(); + return null; } - private RelyingPartyRegistration buildDefaultRelyingPartyRegistration() { - String samlEntityID, samlServiceUri; - IdentityZone zone = retrieveZone(); - if (zone.isUaa()) { - samlEntityID = this.samlEntityID; - samlServiceUri = this.samlEntityID; + private String getZoneEntityId(IdentityZone currentZone) { + String id = currentZone.getConfig().getSamlConfig().getEntityID(); + if (id == null) { + id = samlEntityID; + } + if (currentZone.isUaa()) { + return id; } - else if (zone.getConfig() != null && zone.getConfig().getSamlConfig() != null) { + return "%s.%s".formatted(currentZone.getSubdomain(), id); + } - samlEntityID = zone.getConfig().getSamlConfig().getEntityID(); - samlServiceUri = zone.getSubdomain() + "." + this.samlEntityID; + private String getZoneEntityAlias(IdentityZone currentZone) { + String alias = currentZone.getConfig().getSamlConfig().getEntityID(); + if (alias == null) { + alias = samlEntityIDAlias; + if (alias == null) { + alias = samlEntityID; + } } - else { - return null; + if (currentZone.isUaa()) { + return alias; } - - return RelyingPartyRegistrationBuilder.buildRelyingPartyRegistration( - samlEntityID, null, - keyWithCert, "dummy-saml-idp-metadata.xml", null, - samlServiceUri, zone.getConfig().getSamlConfig().isRequestSigned()); + return "%s.%s".formatted(currentZone.getSubdomain(), alias); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DefaultRelyingPartyRegistrationRepository.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DefaultRelyingPartyRegistrationRepository.java new file mode 100644 index 00000000000..82235d48bb7 --- /dev/null +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DefaultRelyingPartyRegistrationRepository.java @@ -0,0 +1,64 @@ +package org.cloudfoundry.identity.uaa.provider.saml; + +import org.cloudfoundry.identity.uaa.util.KeyWithCert; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.ZoneAware; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; + +/** + * A {@link RelyingPartyRegistrationRepository} that always returns a default {@link RelyingPartyRegistrationRepository}. + */ +public class DefaultRelyingPartyRegistrationRepository implements RelyingPartyRegistrationRepository, ZoneAware { + public static final String CLASSPATH_DUMMY_SAML_IDP_METADATA_XML = "classpath:dummy-saml-idp-metadata.xml"; + + private final KeyWithCert keyWithCert; + private final String samlEntityID; + + private final String samlEntityIDAlias; // TODO consider renaming this to indicate UAA wide + + public DefaultRelyingPartyRegistrationRepository(String samlEntityID, + String samlEntityIDAlias, + KeyWithCert keyWithCert) { + this.keyWithCert = keyWithCert; + this.samlEntityID = samlEntityID; + this.samlEntityIDAlias = samlEntityIDAlias; + } + + /** + * Returns the relying party registration identified by the provided + * {@code registrationId}, or {@code null} if not found. + * + * @param registrationId the registration identifier + * @return the {@link RelyingPartyRegistration} if found, otherwise {@code null} + */ + @Override + public RelyingPartyRegistration findByRegistrationId(String registrationId) { + IdentityZone zone = retrieveZone(); + + boolean requestSigned = true; + if (zone.getConfig() != null && zone.getConfig().getSamlConfig() != null) { + requestSigned = zone.getConfig().getSamlConfig().isRequestSigned(); + } + + String zonedSamlEntityID; + if (!zone.isUaa() && zone.getConfig() != null && zone.getConfig().getSamlConfig() != null && zone.getConfig().getSamlConfig().getEntityID() != null) { + zonedSamlEntityID = zone.getConfig().getSamlConfig().getEntityID(); + } else { + zonedSamlEntityID = this.samlEntityID; + } + + // TODO is this repeating code? + String zonedSamlEntityIDAlias; + if (zone.isUaa()) { // default zone + zonedSamlEntityIDAlias = samlEntityIDAlias; + } else { // non-default zone + zonedSamlEntityIDAlias = "%s.%s".formatted(zone.getSubdomain(), samlEntityIDAlias); + } + + return RelyingPartyRegistrationBuilder.buildRelyingPartyRegistration( + zonedSamlEntityID, null, + keyWithCert, CLASSPATH_DUMMY_SAML_IDP_METADATA_XML, registrationId, + zonedSamlEntityIDAlias, requestSigned); + } +} diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepository.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepository.java index ec71e1c0e86..d63a0bc71f0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepository.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepository.java @@ -1,6 +1,5 @@ package org.cloudfoundry.identity.uaa.provider.saml; -import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.ZoneAware; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; @@ -13,7 +12,7 @@ * A {@link RelyingPartyRegistrationRepository} that delegates to a list of other {@link RelyingPartyRegistrationRepository} * instances. */ -public class DelegatingRelyingPartyRegistrationRepository implements RelyingPartyRegistrationRepository { +public class DelegatingRelyingPartyRegistrationRepository implements RelyingPartyRegistrationRepository, ZoneAware { private final List delegates; @@ -36,11 +35,13 @@ public DelegatingRelyingPartyRegistrationRepository(RelyingPartyRegistrationRepo */ @Override public RelyingPartyRegistration findByRegistrationId(String registrationId) { - boolean isDefaultZone = IdentityZoneHolder.isUaa(); + boolean isDefaultZone = retrieveZone().isUaa(); for (RelyingPartyRegistrationRepository repository : this.delegates) { - RelyingPartyRegistration registration = repository.findByRegistrationId(registrationId); - if (registration != null && (isDefaultZone || repository instanceof ZoneAware)) { - return registration; + if (isDefaultZone || repository instanceof ZoneAware) { + RelyingPartyRegistration registration = repository.findByRegistrationId(registrationId); + if (registration != null) { + return registration; + } } } return null; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilder.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilder.java index 8e78d00aa59..ada55e2413f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilder.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilder.java @@ -24,21 +24,12 @@ private RelyingPartyRegistrationBuilder() { throw new java.lang.UnsupportedOperationException("This is a utility class and cannot be instantiated"); } - public static RelyingPartyRegistration buildRelyingPartyRegistration( - String samlEntityID, String samlSpNameId, - KeyWithCert keyWithCert, - String metadataLocation, String rpRegstrationId, boolean requestSigned) { - return buildRelyingPartyRegistration(samlEntityID, samlSpNameId, - keyWithCert, metadataLocation, rpRegstrationId, - samlEntityID, requestSigned); - } - public static RelyingPartyRegistration buildRelyingPartyRegistration( String samlEntityID, String samlSpNameId, KeyWithCert keyWithCert, String metadataLocation, - String rpRegstrationId, String samlServiceUri, boolean requestSigned) { - SamlIdentityProviderDefinition.MetadataLocation type = SamlIdentityProviderDefinition.getType(metadataLocation); + String rpRegstrationId, String samlSpAlias, boolean requestSigned) { + SamlIdentityProviderDefinition.MetadataLocation type = SamlIdentityProviderDefinition.getType(metadataLocation); RelyingPartyRegistration.Builder builder; if (type == SamlIdentityProviderDefinition.MetadataLocation.DATA) { try (InputStream stringInputStream = new ByteArrayInputStream(metadataLocation.getBytes())) { @@ -51,14 +42,17 @@ public static RelyingPartyRegistration buildRelyingPartyRegistration( builder = RelyingPartyRegistrations.fromMetadataLocation(metadataLocation); } + // fallback to entityId if alias is not provided TODO has the falling back already happened? + samlSpAlias = samlSpAlias == null ? samlEntityID : samlSpAlias; + builder.entityId(samlEntityID); if (samlSpNameId != null) builder.nameIdFormat(samlSpNameId); if (rpRegstrationId != null) builder.registrationId(rpRegstrationId); return builder - .assertionConsumerServiceLocation(assertionConsumerServiceLocationFunction.apply(samlServiceUri)) - .singleLogoutServiceResponseLocation(singleLogoutServiceResponseLocationFunction.apply(samlServiceUri)) - .singleLogoutServiceLocation(singleLogoutServiceLocationFunction.apply(samlServiceUri)) - .singleLogoutServiceResponseLocation(singleLogoutServiceResponseLocationFunction.apply(samlServiceUri)) + .assertionConsumerServiceLocation(assertionConsumerServiceLocationFunction.apply(samlSpAlias)) + .singleLogoutServiceResponseLocation(singleLogoutServiceResponseLocationFunction.apply(samlSpAlias)) + .singleLogoutServiceLocation(singleLogoutServiceLocationFunction.apply(samlSpAlias)) + .singleLogoutServiceResponseLocation(singleLogoutServiceResponseLocationFunction.apply(samlSpAlias)) // Accept both POST and REDIRECT bindings .singleLogoutServiceBindings(c -> { c.add(Saml2MessageBinding.REDIRECT); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlConfigProps.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlConfigProps.java index 5da4684da54..de8fc44c978 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlConfigProps.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlConfigProps.java @@ -13,9 +13,12 @@ public class SamlConfigProps { private String activeKeyId; + private String entityIDAlias; + private Map keys; private Boolean wantAssertionSigned = true; + private Boolean signRequest = true; public SamlKey getActiveSamlKey() { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlRelyingPartyRegistrationRepositoryConfig.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlRelyingPartyRegistrationRepositoryConfig.java index ceae85a8e04..fa35a81302a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlRelyingPartyRegistrationRepositoryConfig.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlRelyingPartyRegistrationRepositoryConfig.java @@ -53,6 +53,8 @@ RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(SamlIdenti List relyingPartyRegistrations = new ArrayList<>(); + String uaaWideSamlEntityIDAlias = samlConfigProps.getEntityIDAlias() != null ? samlConfigProps.getEntityIDAlias() : samlEntityID; + @SuppressWarnings("java:S125") // Spring Security requires at least one relyingPartyRegistration before SAML SP metadata generation; // and each relyingPartyRegistration needs to contain the SAML IDP metadata. @@ -64,9 +66,9 @@ RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(SamlIdenti // here to ensure that the SAML SP metadata will always be present, // even when there are no SAML IDPs configured. // See relevant issue: https://github.com/spring-projects/spring-security/issues/11369 - RelyingPartyRegistration defaultRelyingPartyRegistration = RelyingPartyRegistrationBuilder.buildRelyingPartyRegistration( - samlEntityID, samlSpNameID, keyWithCert, CLASSPATH_DUMMY_SAML_IDP_METADATA_XML, DEFAULT_REGISTRATION_ID, samlConfigProps.getSignRequest()); - relyingPartyRegistrations.add(defaultRelyingPartyRegistration); + RelyingPartyRegistration exampleRelyingPartyRegistration = RelyingPartyRegistrationBuilder.buildRelyingPartyRegistration( + samlEntityID, samlSpNameID, keyWithCert, CLASSPATH_DUMMY_SAML_IDP_METADATA_XML, DEFAULT_REGISTRATION_ID, uaaWideSamlEntityIDAlias, samlConfigProps.getSignRequest()); + relyingPartyRegistrations.add(exampleRelyingPartyRegistration); for (SamlIdentityProviderDefinition samlIdentityProviderDefinition : bootstrapSamlIdentityProviderData.getIdentityProviderDefinitions()) { relyingPartyRegistrations.add( @@ -74,13 +76,15 @@ RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(SamlIdenti samlEntityID, samlSpNameID, keyWithCert, samlIdentityProviderDefinition.getMetaDataLocation(), samlIdentityProviderDefinition.getIdpEntityAlias(), + uaaWideSamlEntityIDAlias, samlConfigProps.getSignRequest()) ); } InMemoryRelyingPartyRegistrationRepository bootstrapRepo = new InMemoryRelyingPartyRegistrationRepository(relyingPartyRegistrations); - ConfiguratorRelyingPartyRegistrationRepository configuratorRepo = new ConfiguratorRelyingPartyRegistrationRepository(samlEntityID, keyWithCert, samlIdentityProviderConfigurator); - return new DelegatingRelyingPartyRegistrationRepository(bootstrapRepo, configuratorRepo); + ConfiguratorRelyingPartyRegistrationRepository configuratorRepo = new ConfiguratorRelyingPartyRegistrationRepository(samlEntityID, uaaWideSamlEntityIDAlias, keyWithCert, samlIdentityProviderConfigurator); + DefaultRelyingPartyRegistrationRepository defaultRepo = new DefaultRelyingPartyRegistrationRepository(samlEntityID, uaaWideSamlEntityIDAlias, keyWithCert); + return new DelegatingRelyingPartyRegistrationRepository(bootstrapRepo, configuratorRepo, defaultRepo); } @Autowired diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepositoryTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepositoryTest.java index a14f3a6ce35..57cc82ba40e 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepositoryTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/ConfiguratorRelyingPartyRegistrationRepositoryTest.java @@ -2,8 +2,10 @@ import org.cloudfoundry.identity.uaa.provider.SamlIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.util.KeyWithCert; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; +import org.cloudfoundry.identity.uaa.zone.SamlConfig; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -28,43 +30,67 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ConfiguratorRelyingPartyRegistrationRepositoryTest { private static final String ENTITY_ID = "entityId"; + private static final String ENTITY_ID_ALIAS = "entityIdAlias"; private static final String REGISTRATION_ID = "registrationId"; + private static final String REGISTRATION_ID_2 = "registrationId2"; private static final String NAME_ID = "name1"; + private static final String UAA_ZONE_ID = "uaa"; + private static final String ZONE_ID = "zoneId"; + private static final String ZONE_DOMAIN = "zoneDomain"; + private static final String ZONED_ENTITY_ID = "zoneDomain.entityId"; @Mock - private SamlIdentityProviderConfigurator mockConfigurator; + private SamlIdentityProviderConfigurator configurator; @Mock - private KeyWithCert mockKeyWithCert; + private IdentityZone identityZone; + + @Mock + private KeyWithCert keyWithCert; + + @Mock + private SamlIdentityProviderDefinition definition; + + @Mock + private IdentityZoneConfiguration identityZoneConfiguration; + + @Mock + private SamlConfig samlConfig; private ConfiguratorRelyingPartyRegistrationRepository repository; @BeforeEach void setUp() { - repository = new ConfiguratorRelyingPartyRegistrationRepository(ENTITY_ID, mockKeyWithCert, - mockConfigurator); + repository = spy(new ConfiguratorRelyingPartyRegistrationRepository(ENTITY_ID, ENTITY_ID_ALIAS, keyWithCert, + configurator)); } @Test void constructorWithNullConfiguratorThrows() { assertThatThrownBy(() -> new ConfiguratorRelyingPartyRegistrationRepository( - ENTITY_ID, mockKeyWithCert, null) + ENTITY_ID, ENTITY_ID_ALIAS, keyWithCert, null) ).isInstanceOf(IllegalArgumentException.class); } @Test void findByRegistrationIdWithMultipleInDb() { - when(mockKeyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); - when(mockKeyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.getId()).thenReturn(UAA_ZONE_ID); + when(identityZone.isUaa()).thenReturn(true); + when(identityZone.getConfig()).thenReturn(identityZoneConfiguration); + when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig); + when(keyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); + when(keyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); //definition 1 - SamlIdentityProviderDefinition definition = mock(SamlIdentityProviderDefinition.class); when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID); + when(definition.getZoneId()).thenReturn(UAA_ZONE_ID); when(definition.getNameID()).thenReturn(NAME_ID); when(definition.getMetaDataLocation()).thenReturn("saml-sample-metadata.xml"); @@ -73,7 +99,7 @@ void findByRegistrationIdWithMultipleInDb() { when(otherDefinition.getIdpEntityAlias()).thenReturn("otherRegistrationId"); SamlIdentityProviderDefinition anotherDefinition = mock(SamlIdentityProviderDefinition.class); - when(mockConfigurator.getIdentityProviderDefinitions()).thenReturn(Arrays.asList(otherDefinition, definition, anotherDefinition)); + when(configurator.getIdentityProviderDefinitions()).thenReturn(Arrays.asList(otherDefinition, definition, anotherDefinition)); RelyingPartyRegistration registration = repository.findByRegistrationId(REGISTRATION_ID); assertThat(registration) // from definition @@ -81,79 +107,126 @@ void findByRegistrationIdWithMultipleInDb() { .returns(ENTITY_ID, RelyingPartyRegistration::getEntityId) .returns(NAME_ID, RelyingPartyRegistration::getNameIdFormat) // from functions - .returns("{baseUrl}/saml/SSO/alias/entityId", RelyingPartyRegistration::getAssertionConsumerServiceLocation) - .returns("{baseUrl}/saml/SingleLogout/alias/entityId", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + .returns("{baseUrl}/saml/SSO/alias/entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) // from xml .extracting(RelyingPartyRegistration::getAssertingPartyDetails) .returns("https://idp-saml.ua3.int/simplesaml/saml2/idp/metadata.php", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); } @Test - @Disabled("Test not valid because ConfiguratorRelyingPartyRegistrationRepository now returns default RelyingPartyRegistration when none found") void findByRegistrationIdWhenNoneFound() { - SamlIdentityProviderDefinition definition = mock(SamlIdentityProviderDefinition.class); when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID); - when(mockConfigurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); + when(configurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); assertThat(repository.findByRegistrationId("registrationIdNotFound")).isNull(); } @Test void buildsCorrectRegistrationWhenMetadataXmlIsStored() { - String metadata = loadResouceAsString("no_single_logout_service-metadata.xml"); - when(mockKeyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); - when(mockKeyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); - SamlIdentityProviderDefinition definition = mock(SamlIdentityProviderDefinition.class); - when(definition.getIdpEntityAlias()).thenReturn("no_slos"); + String metadata = loadResouceAsString("saml-sample-metadata.xml"); + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.getId()).thenReturn(UAA_ZONE_ID); + when(identityZone.isUaa()).thenReturn(true); + when(identityZone.getConfig()).thenReturn(identityZoneConfiguration); + when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig); + + when(keyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); + when(keyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID); when(definition.getNameID()).thenReturn(NAME_ID); when(definition.getMetaDataLocation()).thenReturn(metadata); - when(mockConfigurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); + when(definition.getZoneId()).thenReturn(UAA_ZONE_ID); + when(configurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); - RelyingPartyRegistration registration = repository.findByRegistrationId("no_slos"); + RelyingPartyRegistration registration = repository.findByRegistrationId(REGISTRATION_ID); assertThat(registration) // from definition - .returns("no_slos", RelyingPartyRegistration::getRegistrationId) + .returns(REGISTRATION_ID, RelyingPartyRegistration::getRegistrationId) .returns(ENTITY_ID, RelyingPartyRegistration::getEntityId) .returns(NAME_ID, RelyingPartyRegistration::getNameIdFormat) // from functions - .returns("{baseUrl}/saml/SSO/alias/entityId", RelyingPartyRegistration::getAssertionConsumerServiceLocation) - .returns("{baseUrl}/saml/SingleLogout/alias/entityId", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + .returns("{baseUrl}/saml/SSO/alias/entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) // from xml .extracting(RelyingPartyRegistration::getAssertingPartyDetails) - .returns("http://uaa-acceptance.cf-app.com/saml-idp", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); + .returns("https://idp-saml.ua3.int/simplesaml/saml2/idp/metadata.php", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); } @Test void buildsCorrectRegistrationWhenMetadataLocationIsStored() { - when(mockKeyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); - when(mockKeyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); - SamlIdentityProviderDefinition definition = mock(SamlIdentityProviderDefinition.class); - when(definition.getIdpEntityAlias()).thenReturn("no_slos"); + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.getId()).thenReturn(UAA_ZONE_ID); + when(identityZone.isUaa()).thenReturn(true); + when(identityZone.getConfig()).thenReturn(identityZoneConfiguration); + when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig); + + when(keyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); + when(keyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID_2); when(definition.getNameID()).thenReturn(NAME_ID); - when(definition.getMetaDataLocation()).thenReturn("no_single_logout_service-metadata.xml"); - when(mockConfigurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); + when(definition.getZoneId()).thenReturn(UAA_ZONE_ID); + when(definition.getMetaDataLocation()).thenReturn("saml-sample-metadata.xml"); + when(configurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); - RelyingPartyRegistration registration = repository.findByRegistrationId("no_slos"); + RelyingPartyRegistration registration = repository.findByRegistrationId(REGISTRATION_ID_2); assertThat(registration) // from definition - .returns("no_slos", RelyingPartyRegistration::getRegistrationId) + .returns(REGISTRATION_ID_2, RelyingPartyRegistration::getRegistrationId) .returns(ENTITY_ID, RelyingPartyRegistration::getEntityId) .returns(NAME_ID, RelyingPartyRegistration::getNameIdFormat) // from functions - .returns("{baseUrl}/saml/SSO/alias/entityId", RelyingPartyRegistration::getAssertionConsumerServiceLocation) - .returns("{baseUrl}/saml/SingleLogout/alias/entityId", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + .returns("{baseUrl}/saml/SSO/alias/entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) // from xml .extracting(RelyingPartyRegistration::getAssertingPartyDetails) - .returns("http://uaa-acceptance.cf-app.com/saml-idp", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); + .returns("https://idp-saml.ua3.int/simplesaml/saml2/idp/metadata.php", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); + } + + @Test + void buildsCorrectRegistrationWhenZoneIdIsStored() { + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.getId()).thenReturn(ZONE_ID); + when(identityZone.isUaa()).thenReturn(false); + when(identityZone.getSubdomain()).thenReturn(ZONE_DOMAIN); + when(identityZone.getConfig()).thenReturn(identityZoneConfiguration); + when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig); + + when(keyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); + when(keyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID); + when(definition.getNameID()).thenReturn(NAME_ID); + when(definition.getZoneId()).thenReturn(ZONE_ID); + when(definition.getMetaDataLocation()).thenReturn("saml-sample-metadata.xml"); + when(configurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); + + RelyingPartyRegistration registration = repository.findByRegistrationId(REGISTRATION_ID); + assertThat(registration) + // from definition + .returns(REGISTRATION_ID, RelyingPartyRegistration::getRegistrationId) + .returns(ZONED_ENTITY_ID, RelyingPartyRegistration::getEntityId) + .returns(NAME_ID, RelyingPartyRegistration::getNameIdFormat) + // from functions + .returns("{baseUrl}/saml/SSO/alias/zoneDomain.entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/zoneDomain.entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + // from xml + .extracting(RelyingPartyRegistration::getAssertingPartyDetails) + .returns("https://idp-saml.ua3.int/simplesaml/saml2/idp/metadata.php", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); } @Test void failsWhenInvalidMetadataLocationIsStored() { - SamlIdentityProviderDefinition definition = mock(SamlIdentityProviderDefinition.class); + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.getId()).thenReturn(UAA_ZONE_ID); + when(identityZone.isUaa()).thenReturn(true); + when(identityZone.getConfig()).thenReturn(identityZoneConfiguration); + when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig); + + when(definition.getZoneId()).thenReturn(UAA_ZONE_ID); when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID); when(definition.getMetaDataLocation()).thenReturn("not_found_metadata.xml"); - when(mockConfigurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); + when(configurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); assertThatThrownBy(() -> repository.findByRegistrationId(REGISTRATION_ID)) .isInstanceOf(Saml2Exception.class) @@ -162,10 +235,16 @@ void failsWhenInvalidMetadataLocationIsStored() { @Test void failsWhenInvalidMetadataXmlIsStored() { - SamlIdentityProviderDefinition definition = mock(SamlIdentityProviderDefinition.class); + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.getId()).thenReturn(UAA_ZONE_ID); + when(identityZone.isUaa()).thenReturn(true); + when(identityZone.getConfig()).thenReturn(identityZoneConfiguration); + when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig); + + when(definition.getZoneId()).thenReturn(UAA_ZONE_ID); when(definition.getIdpEntityAlias()).thenReturn(REGISTRATION_ID); when(definition.getMetaDataLocation()).thenReturn("\ninvalid xml"); - when(mockConfigurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); + when(configurator.getIdentityProviderDefinitions()).thenReturn(List.of(definition)); assertThatThrownBy(() -> repository.findByRegistrationId(REGISTRATION_ID)) .isInstanceOf(Saml2Exception.class) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DefaultRelyingPartyRegistrationRepositoryTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DefaultRelyingPartyRegistrationRepositoryTest.java new file mode 100644 index 00000000000..f299f575bfa --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DefaultRelyingPartyRegistrationRepositoryTest.java @@ -0,0 +1,95 @@ +package org.cloudfoundry.identity.uaa.provider.saml; + +import org.cloudfoundry.identity.uaa.util.KeyWithCert; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; +import org.cloudfoundry.identity.uaa.zone.SamlConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultRelyingPartyRegistrationRepositoryTest { + private static final String ENTITY_ID = "entityId"; + private static final String ENTITY_ID_ALIAS = "entityIdAlias"; + private static final String ZONE_SUBDOMAIN = "testzone"; + private static final String ZONED_ENTITY_ID = "%s.%s".formatted(ZONE_SUBDOMAIN, ENTITY_ID); + private static final String REGISTRATION_ID = "registrationId"; + private static final String NAME_ID = "name1"; + + @Mock + private KeyWithCert mockKeyWithCert; + + @Mock + private IdentityZone identityZone; + + @Mock + private IdentityZoneConfiguration identityZoneConfig; + + @Mock + private SamlConfig samlConfig; + + private DefaultRelyingPartyRegistrationRepository repository; + + @BeforeEach + void setUp() { + repository = spy(new DefaultRelyingPartyRegistrationRepository(ENTITY_ID, ENTITY_ID_ALIAS, mockKeyWithCert)); + } + + @Test + void findByRegistrationId() { + when(mockKeyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); + when(mockKeyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + + RelyingPartyRegistration registration = repository.findByRegistrationId(REGISTRATION_ID); + + assertThat(registration) + // from definition + .returns(REGISTRATION_ID, RelyingPartyRegistration::getRegistrationId) + .returns(ENTITY_ID, RelyingPartyRegistration::getEntityId) + .returns(null, RelyingPartyRegistration::getNameIdFormat) + // from functions + .returns("{baseUrl}/saml/SSO/alias/entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + // from xml + .extracting(RelyingPartyRegistration::getAssertingPartyDetails) + .returns("exampleEntityId", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); + } + + @Test + void findByRegistrationIdForZone() { + when(mockKeyWithCert.getCertificate()).thenReturn(mock(X509Certificate.class)); + when(mockKeyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); + when(repository.retrieveZone()).thenReturn(identityZone); + when(identityZone.isUaa()).thenReturn(false); + when(identityZone.getConfig()).thenReturn(identityZoneConfig); + when(identityZone.getSubdomain()).thenReturn(ZONE_SUBDOMAIN); + when(identityZoneConfig.getSamlConfig()).thenReturn(samlConfig); + when(samlConfig.getEntityID()).thenReturn(ZONED_ENTITY_ID); + + RelyingPartyRegistration registration = repository.findByRegistrationId(REGISTRATION_ID); + + assertThat(registration) + // from definition + .returns(REGISTRATION_ID, RelyingPartyRegistration::getRegistrationId) + .returns(ZONED_ENTITY_ID, RelyingPartyRegistration::getEntityId) + .returns(null, RelyingPartyRegistration::getNameIdFormat) + // from functions + .returns("{baseUrl}/saml/SSO/alias/testzone.entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/testzone.entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + // from xml + .extracting(RelyingPartyRegistration::getAssertingPartyDetails) + .returns("exampleEntityId", RelyingPartyRegistration.AssertingPartyDetails::getEntityId); + } +} \ No newline at end of file diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepositoryTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepositoryTest.java index 4b849048085..8f6e3fa3b68 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepositoryTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/DelegatingRelyingPartyRegistrationRepositoryTest.java @@ -1,40 +1,53 @@ package org.cloudfoundry.identity.uaa.provider.saml; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import java.util.Collections; import java.util.List; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) class DelegatingRelyingPartyRegistrationRepositoryTest { + private static final String REGISTRATION_ID = "test"; + + @Mock + IdentityZone identityZone; + @Test void constructor_WhenRepositoriesAreNull() { - assertThrows(IllegalArgumentException.class, () -> { + assertThatThrownBy(() -> { new DelegatingRelyingPartyRegistrationRepository((List) null); - }); + }).isInstanceOf(IllegalArgumentException.class); - assertThrows(IllegalArgumentException.class, () -> { + assertThatThrownBy(() -> { new DelegatingRelyingPartyRegistrationRepository((RelyingPartyRegistrationRepository[]) null); - }); + }).isInstanceOf(IllegalArgumentException.class); } @Test void constructor_whenRepositoriesAreEmpty() { - assertThrows(IllegalArgumentException.class, () -> { + assertThatThrownBy(() -> { new DelegatingRelyingPartyRegistrationRepository(Collections.emptyList()); - }); + }).isInstanceOf(IllegalArgumentException.class); - assertThrows(IllegalArgumentException.class, () -> { + assertThatThrownBy(() -> { new DelegatingRelyingPartyRegistrationRepository(new RelyingPartyRegistrationRepository[]{}); - }); + }).isInstanceOf(IllegalArgumentException.class); } @Test @@ -42,19 +55,39 @@ void findWhenRegistrationNotFound() { RelyingPartyRegistrationRepository mockRepository = mock(RelyingPartyRegistrationRepository.class); when(mockRepository.findByRegistrationId(anyString())).thenReturn(null); DelegatingRelyingPartyRegistrationRepository target = new DelegatingRelyingPartyRegistrationRepository(mockRepository); - assertNull(target.findByRegistrationId("test")); + assertThat(target.findByRegistrationId(REGISTRATION_ID)).isNull(); } @Test void findWhenRegistrationFound() { RelyingPartyRegistration expectedRegistration = mock(RelyingPartyRegistration.class); RelyingPartyRegistrationRepository mockRepository1 = mock(RelyingPartyRegistrationRepository.class); - when(mockRepository1.findByRegistrationId(eq("test"))).thenReturn(null); RelyingPartyRegistrationRepository mockRepository2 = mock(RelyingPartyRegistrationRepository.class); - when(mockRepository2.findByRegistrationId(eq("test"))).thenReturn(expectedRegistration); + when(mockRepository2.findByRegistrationId(REGISTRATION_ID)).thenReturn(expectedRegistration); DelegatingRelyingPartyRegistrationRepository target = new DelegatingRelyingPartyRegistrationRepository(mockRepository1, mockRepository2); - assertEquals(expectedRegistration, target.findByRegistrationId("test")); + assertThat(target.findByRegistrationId(REGISTRATION_ID)).isEqualTo(expectedRegistration); + + verify(mockRepository1).findByRegistrationId(REGISTRATION_ID); + verify(mockRepository2).findByRegistrationId(REGISTRATION_ID); + } + + @Test + void findWhenZonedRegistrationFound() { + when(identityZone.isUaa()).thenReturn(false); + + RelyingPartyRegistration expectedRegistration = mock(RelyingPartyRegistration.class); + RelyingPartyRegistrationRepository mockRepository1 = mock(RelyingPartyRegistrationRepository.class); + + RelyingPartyRegistrationRepository mockRepository2 = mock(DefaultRelyingPartyRegistrationRepository.class); + when(mockRepository2.findByRegistrationId(REGISTRATION_ID)).thenReturn(expectedRegistration); + + DelegatingRelyingPartyRegistrationRepository target = spy(new DelegatingRelyingPartyRegistrationRepository(mockRepository1, mockRepository2)); + when(target.retrieveZone()).thenReturn(identityZone); + assertThat(target.findByRegistrationId(REGISTRATION_ID)).isEqualTo(expectedRegistration); + + // is not ZoneAware, so it should not call findByRegistrationId + verify(mockRepository1, never()).findByRegistrationId(REGISTRATION_ID); } -} \ No newline at end of file +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilderTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilderTest.java index 763cd8d47a8..00e38908fcb 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilderTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/RelyingPartyRegistrationBuilderTest.java @@ -29,6 +29,7 @@ class RelyingPartyRegistrationBuilderTest { private static final String ENTITY_ID = "entityId"; + private static final String ENTITY_ID_ALIAS = "entityIdAlias"; private static final String NAME_ID = "nameIdFormat"; private static final String REGISTRATION_ID = "registrationId"; @@ -41,14 +42,14 @@ void buildsRelyingPartyRegistrationFromLocation() { when(mockKeyWithCert.getPrivateKey()).thenReturn(mock(PrivateKey.class)); RelyingPartyRegistration registration = RelyingPartyRegistrationBuilder - .buildRelyingPartyRegistration(ENTITY_ID, NAME_ID, mockKeyWithCert, "saml-sample-metadata.xml", REGISTRATION_ID, true); + .buildRelyingPartyRegistration(ENTITY_ID, NAME_ID, mockKeyWithCert, "saml-sample-metadata.xml", REGISTRATION_ID, ENTITY_ID_ALIAS, true); assertThat(registration) .returns(REGISTRATION_ID, RelyingPartyRegistration::getRegistrationId) .returns(ENTITY_ID, RelyingPartyRegistration::getEntityId) .returns(NAME_ID, RelyingPartyRegistration::getNameIdFormat) // from functions - .returns("{baseUrl}/saml/SSO/alias/entityId", RelyingPartyRegistration::getAssertionConsumerServiceLocation) - .returns("{baseUrl}/saml/SingleLogout/alias/entityId", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + .returns("{baseUrl}/saml/SSO/alias/entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) // from xml .extracting(RelyingPartyRegistration::getAssertingPartyDetails) .returns("https://idp-saml.ua3.int/simplesaml/saml2/idp/metadata.php", RelyingPartyRegistration.AssertingPartyDetails::getEntityId) @@ -62,15 +63,15 @@ void buildsRelyingPartyRegistrationFromXML() { String metadataXml = loadResouceAsString("saml-sample-metadata.xml"); RelyingPartyRegistration registration = RelyingPartyRegistrationBuilder - .buildRelyingPartyRegistration(ENTITY_ID, NAME_ID, mockKeyWithCert, metadataXml, REGISTRATION_ID, false); + .buildRelyingPartyRegistration(ENTITY_ID, NAME_ID, mockKeyWithCert, metadataXml, REGISTRATION_ID, ENTITY_ID_ALIAS,false); assertThat(registration) .returns(REGISTRATION_ID, RelyingPartyRegistration::getRegistrationId) .returns(ENTITY_ID, RelyingPartyRegistration::getEntityId) .returns(NAME_ID, RelyingPartyRegistration::getNameIdFormat) // from functions - .returns("{baseUrl}/saml/SSO/alias/entityId", RelyingPartyRegistration::getAssertionConsumerServiceLocation) - .returns("{baseUrl}/saml/SingleLogout/alias/entityId", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) + .returns("{baseUrl}/saml/SSO/alias/entityIdAlias", RelyingPartyRegistration::getAssertionConsumerServiceLocation) + .returns("{baseUrl}/saml/SingleLogout/alias/entityIdAlias", RelyingPartyRegistration::getSingleLogoutServiceResponseLocation) // from xml .extracting(RelyingPartyRegistration::getAssertingPartyDetails) .returns("https://idp-saml.ua3.int/simplesaml/saml2/idp/metadata.php", RelyingPartyRegistration.AssertingPartyDetails::getEntityId) @@ -82,7 +83,7 @@ void failsWithInvalidXML() { String metadataXml = "\ninvalid xml"; assertThatThrownBy(() -> RelyingPartyRegistrationBuilder.buildRelyingPartyRegistration(ENTITY_ID, NAME_ID, - mockKeyWithCert, metadataXml, REGISTRATION_ID, true)) + mockKeyWithCert, metadataXml, REGISTRATION_ID, ENTITY_ID_ALIAS, true)) .isInstanceOf(Saml2Exception.class) .hasMessageContaining("Unsupported element"); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointTest.java index 64b63def067..4a83a429213 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlMetadataEndpointTest.java @@ -11,7 +11,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; -import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; @@ -28,9 +27,11 @@ @ExtendWith(MockitoExtension.class) class SamlMetadataEndpointTest { - private static final String ASSERTION_CONSUMER_SERVICE = "https://acsl"; + private static final String ASSERTION_CONSUMER_SERVICE = "{baseUrl}/saml/SSO/alias/"; private static final String REGISTRATION_ID = "regId"; private static final String ENTITY_ID = "entityId"; + private static final String ZONE_ENTITY_ID = "zoneEntityId"; + private static final String TEST_ZONE = "testzone1"; SamlMetadataEndpoint endpoint; @@ -50,7 +51,6 @@ class SamlMetadataEndpointTest { @BeforeEach void setUp() { endpoint = spy(new SamlMetadataEndpoint(repository, identityZoneManager)); - when(repository.findByRegistrationId(REGISTRATION_ID)).thenReturn(registration); when(registration.getEntityId()).thenReturn(ENTITY_ID); when(registration.getSigningX509Credentials()).thenReturn(List.of(relyingPartySigningCredential())); when(registration.getDecryptionX509Credentials()).thenReturn(List.of(relyingPartyVerifyingCredential())); @@ -63,6 +63,8 @@ void setUp() { @Test void testDefaultFileName() { + when(repository.findByRegistrationId(REGISTRATION_ID)).thenReturn(registration); + ResponseEntity response = endpoint.metadataEndpoint(REGISTRATION_ID); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) .isEqualTo("attachment; filename=\"saml-sp.xml\"; filename*=UTF-8''saml-sp.xml"); @@ -70,22 +72,24 @@ void testDefaultFileName() { @Test void testZonedFileName() { + when(repository.findByRegistrationId(REGISTRATION_ID)).thenReturn(registration); when(identityZone.isUaa()).thenReturn(false); - when(identityZone.getSubdomain()).thenReturn("testzone1"); + when(identityZone.getSubdomain()).thenReturn(TEST_ZONE); when(endpoint.retrieveZone()).thenReturn(identityZone); ResponseEntity response = endpoint.metadataEndpoint(REGISTRATION_ID); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) - .isEqualTo("attachment; filename=\"saml-testzone1-sp.xml\"; filename*=UTF-8''saml-testzone1-sp.xml"); + .isEqualTo("attachment; filename=\"saml-%1$s-sp.xml\"; filename*=UTF-8''saml-%1$s-sp.xml".formatted(TEST_ZONE)); } @Test void testDefaultMetadataXml() { + when(repository.findByRegistrationId(REGISTRATION_ID)).thenReturn(registration); when(samlConfig.isWantAssertionSigned()).thenReturn(true); when(samlConfig.isRequestSigned()).thenReturn(true); ResponseEntity response = endpoint.metadataEndpoint(REGISTRATION_ID); - XmlAssert xmlAssert =XmlAssert.assertThat(response.getBody()).withNamespaceContext(xmlNamespaces()); + XmlAssert xmlAssert = XmlAssert.assertThat(response.getBody()).withNamespaceContext(xmlNamespaces()); xmlAssert.valueByXPath("//md:EntityDescriptor/@entityID").isEqualTo(ENTITY_ID); xmlAssert.valueByXPath("//md:SPSSODescriptor/@AuthnRequestsSigned").isEqualTo(true); xmlAssert.valueByXPath("//md:SPSSODescriptor/@WantAssertionsSigned").isEqualTo(true); @@ -94,11 +98,12 @@ void testDefaultMetadataXml() { @Test void testDefaultMetadataXml_alternateValues() { + when(repository.findByRegistrationId(REGISTRATION_ID)).thenReturn(registration); when(samlConfig.isWantAssertionSigned()).thenReturn(false); when(samlConfig.isRequestSigned()).thenReturn(false); ResponseEntity response = endpoint.metadataEndpoint(REGISTRATION_ID); - XmlAssert xmlAssert =XmlAssert.assertThat(response.getBody()).withNamespaceContext(xmlNamespaces()); + XmlAssert xmlAssert = XmlAssert.assertThat(response.getBody()).withNamespaceContext(xmlNamespaces()); xmlAssert.valueByXPath("//md:SPSSODescriptor/@AuthnRequestsSigned").isEqualTo(false); xmlAssert.valueByXPath("//md:SPSSODescriptor/@WantAssertionsSigned").isEqualTo(false); } diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 961cb01f480..189a24b4ac8 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -391,10 +391,12 @@ login: # SAML - The entity base url is the location of this application # (The host and port of the application that will accept assertions) entityBaseURL: http://localhost:8080/uaa - # The entityID of this SP + # The entityID of this SP (SAML SP metadata will declare this as "entityID"); SAML Authn Request will use this as the "Issuer" of the request entityID: cloudfoundry-saml-login saml: - #Entity ID Alias to login at /saml/SSO/alias/{login.saml.entityIDAlias} + # Entity ID Alias to login at /saml/SSO/alias/{login.saml.entityIDAlias}; + # both SAML SP metadata and SAML Authn Request will include this as part of various SAML URLs (such as the AssertionConsumerService URL); + # if not set, UAA will fall back to login.entityID #entityIDAlias: cloudfoundry-saml-login #Default nameID if IDP nameID is not set nameID: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/SamlLoginIT.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/SamlLoginIT.java index 3bba2fb1226..57a8ebec273 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/SamlLoginIT.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/SamlLoginIT.java @@ -227,6 +227,7 @@ void samlSPMetadata() { // login.saml.wantAssertionSigned xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/@WantAssertionsSigned").isEqualTo(true); // TODO the AssertionConsumerService location needs to be a valid URL (currently it's: {baseUrl}/saml/....) + // the AssertionConsumerService endpoint needs to be: /saml/SSO/alias/[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias or login.entityID] xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/md:AssertionConsumerService/@Location").contains("/saml/SSO/alias/cloudfoundry-saml-login"); // assertThat(metadataXml).contains("entityID=\"cloudfoundry-saml-login\"") @@ -269,14 +270,15 @@ void samlSPMetadataForZone() { XmlAssert xmlAssert = XmlAssert.assertThat(metadataXml).withNamespaceContext(xmlNamespaces()); // The SAML SP metadata should match the following UAA configs: - // login.entityID + // id zone config's samlConfig.entityID xmlAssert.valueByXPath("//md:EntityDescriptor/@entityID").isEqualTo("testzone1-saml-login"); - // in default zone, determined by UAA.yml field: login.saml.signRequest; in other zone, determined by zone config field: config.samlConfig.requestSigned + // determined by zone config field: config.samlConfig.requestSigned xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/@AuthnRequestsSigned").isEqualTo(false); - // in default zone, determined by UAA.yml field: login.saml.wantAssertionSigned; in other zone, determined by zone config field: config.samlConfig.wantAssertionSigned + // determined by zone config field: config.samlConfig.wantAssertionSigned xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/@WantAssertionsSigned").isEqualTo(false); // TODO the AssertionConsumerService location needs to be a valid URL (currently it's: {baseUrl}/saml/....) + // the AssertionConsumerService endpoint needs to be: /saml/SSO/alias/[zone-subdomain].[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias, or fall back on login.entityID] xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/md:AssertionConsumerService/@Location").contains("/saml/SSO/alias/testzone1.cloudfoundry-saml-login"); assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo("saml-testzone1-sp.xml"); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataMockMvcTests.java index f86e66474cf..be41c8da423 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/saml/SamlMetadataMockMvcTests.java @@ -1,19 +1,33 @@ package org.cloudfoundry.identity.uaa.mock.saml; -import java.net.URI; - +import org.cloudfoundry.identity.uaa.DefaultTestContext; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.provider.AbstractIdentityProviderDefinition; +import org.cloudfoundry.identity.uaa.provider.IdentityProvider; +import org.cloudfoundry.identity.uaa.provider.SamlIdentityProviderDefinition; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; -import org.cloudfoundry.identity.uaa.DefaultTestContext; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.springframework.web.context.WebApplicationContext; + +import java.net.URI; import static org.hamcrest.Matchers.containsString; +import static org.springframework.http.HttpHeaders.HOST; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; @DefaultTestContext class SamlMetadataMockMvcTests { @@ -21,6 +35,23 @@ class SamlMetadataMockMvcTests { @Autowired private MockMvc mockMvc; + private RandomValueStringGenerator generator; + private IdentityZone spZone; + + @Autowired + private WebApplicationContext webApplicationContext; + private UaaClientDetails adminClient; + + @BeforeEach + void setUp() throws Exception { + adminClient = new UaaClientDetails("admin", "", "", "client_credentials", "uaa.admin"); + adminClient.setClientSecret("adminsecret"); + + generator = new RandomValueStringGenerator(); + String zoneSubdomain = "testzone-" + generator.generate(); + spZone = createZone(zoneSubdomain, adminClient, false, false, zoneSubdomain + "-entity-id"); + } + @Test void testSamlMetadataRootNoEndingSlash() throws Exception { mockMvc.perform(get(new URI("/saml/metadata"))) @@ -58,27 +89,103 @@ void testSamlMetadataXMLValidation() throws Exception { xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(true), // matches UAA config login.saml.wantAssertionSigned xpath("/EntityDescriptor/SPSSODescriptor/NameIDFormat").string("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"), // matches UAA config login.saml.NameID xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='signing']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), - xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0=") + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(containsString("/saml/SSO/alias/integration-saml-entity-id")) // last path is the UAA-wide entity ID alias, set by login.saml.entityIDAlias; is not set, fall back to login.entityID + ); + } + + @Test + void testNonDefaultZoneSamlMetadataXMLValidation() throws Exception { + String subdomain = spZone.getSubdomain(); + + mockMvc.perform(get(new URI("/saml/metadata")) + .header(HOST, subdomain + ".localhost:8080")) + .andDo(print()) + .andExpectAll( + status().isOk(), + header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename=\"saml-%s-sp.xml\";".formatted(subdomain))), + xpath("/EntityDescriptor/@entityID").string(spZone.getConfig().getSamlConfig().getEntityID()), // matches zone config samlConfig.entityID, or fall back on UAA config login.entityID + xpath("/EntityDescriptor/SPSSODescriptor/@AuthnRequestsSigned").booleanValue(false), // matches zone config samlConfig.requestSigned + xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(false), // matches zone config samlConfig.wantAssertionSigned + //xpath("/EntityDescriptor/SPSSODescriptor/NameIDFormat").string("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"), // TODO matches UAA config login.saml.NameID??? + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='signing']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(containsString("/saml/SSO/alias/%s.integration-saml-entity-id".formatted(subdomain))) // this needs to be: /saml/SSO/alias/[zone-subdomain].[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias, or fall back on login.entityID in this case] ); } @Nested @DefaultTestContext - @TestPropertySource(properties = {"login.saml.signRequest = false", "login.saml.wantAssertionSigned = false"}) + @TestPropertySource(properties = {"login.saml.signRequest = false", + "login.saml.wantAssertionSigned = false", + "login.saml.entityIDAlias = integration-saml-entity-id-alias"}) class SamlMetadataAlternativeConfigsMockMvcTests { @Autowired private MockMvc mockMvc; @Test - void testSamlMetadataAuthnRequestsSignedIsFalse() throws Exception { + void testSamlMetadataXMLValidation() throws Exception { + mockMvc.perform(get(new URI("/saml/metadata"))) .andDo(print()) .andExpectAll( status().isOk(), header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename=\"saml-sp.xml\";")), + xpath("/EntityDescriptor/@entityID").string("integration-saml-entity-id"), // matches UAA config login.entityID xpath("/EntityDescriptor/SPSSODescriptor/@AuthnRequestsSigned").booleanValue(false), // matches UAA config login.saml.signRequest - xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(false) // matches UAA config login.saml.wantAssertionSigned + xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(false), // matches UAA config login.saml.wantAssertionSigned + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(containsString("/saml/SSO/alias/integration-saml-entity-id-alias")) // path contains login.saml.entityIDAlias + ); + } + + @Test + void testNonDefaultZoneSamlMetadataXMLValidation() throws Exception { + String subdomain = spZone.getSubdomain(); + + mockMvc.perform(get(new URI("/saml/metadata")) + .header(HOST, subdomain + ".localhost:8080")) + .andDo(print()) + .andExpectAll( + status().isOk(), + header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename=\"saml-%s-sp.xml\";".formatted(subdomain))), + xpath("/EntityDescriptor/@entityID").string(spZone.getConfig().getSamlConfig().getEntityID()), // matches zone config samlConfig.entityID, or fall back on UAA config login.entityID + xpath("/EntityDescriptor/SPSSODescriptor/@AuthnRequestsSigned").booleanValue(false), // matches zone config samlConfig.requestSigned + xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(false), // matches zone config samlConfig.wantAssertionSigned + //xpath("/EntityDescriptor/SPSSODescriptor/NameIDFormat").string("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"), // TODO matches UAA config login.saml.NameID??? + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='signing']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(containsString("/saml/SSO/alias/%s.integration-saml-entity-id-alias".formatted(subdomain))) // this needs to be: /saml/SSO/alias/[zone-subdomain].[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias, or fall back on login.entityID] ); } + + @Test + void testNonDefaultZoneSamlMetadataXMLValidation_ZoneSamlEntityIDNotSet() throws Exception { + generator = new RandomValueStringGenerator(); + String zoneSubdomain = "testzone-" + generator.generate().toLowerCase(); // TODO Why is this lowercase needed here only? + IdentityZone alternativeSpZone = createZone(zoneSubdomain, adminClient, false, false, null); + + mockMvc.perform(get(new URI("/saml/metadata")) + .header(HOST, zoneSubdomain + ".localhost:8080")) + .andDo(print()) + .andExpectAll( + status().isOk(), + header().string(HttpHeaders.CONTENT_DISPOSITION, containsString("filename=\"saml-%s-sp.xml\";".formatted(zoneSubdomain))), + xpath("/EntityDescriptor/@entityID").string("integration-saml-entity-id"), // matches zone config samlConfig.entityID, or fall back on UAA config login.entityID + xpath("/EntityDescriptor/SPSSODescriptor/@AuthnRequestsSigned").booleanValue(false), // matches zone config samlConfig.requestSigned + xpath("/EntityDescriptor/SPSSODescriptor/@WantAssertionsSigned").booleanValue(false), // matches zone config samlConfig.wantAssertionSigned + //xpath("/EntityDescriptor/SPSSODescriptor/NameIDFormat").string("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"), // TODO matches UAA config login.saml.NameID??? + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='signing']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/KeyDescriptor[@use='encryption']/KeyInfo/X509Data/X509Certificate").string("MIIDSTCCArKgAwIBAgIBADANBgkqhkiG9w0BAQQFADB8MQswCQYDVQQGEwJhdzEOMAwGA1UECBMFYXJ1YmExDjAMBgNVBAoTBWFydWJhMQ4wDAYDVQQHEwVhcnViYTEOMAwGA1UECxMFYXJ1YmExDjAMBgNVBAMTBWFydWJhMR0wGwYJKoZIhvcNAQkBFg5hcnViYUBhcnViYS5hcjAeFw0xNTExMjAyMjI2MjdaFw0xNjExMTkyMjI2MjdaMHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDHtC5gUXxBKpEqZTLkNvFwNGnNIkggNOwOQVNbpO0WVHIivig5L39WqS9u0hnA+O7MCA/KlrAR4bXaeVVhwfUPYBKIpaaTWFQR5cTR1UFZJL/OF9vAfpOwznoD66DDCnQVpbCjtDYWX+x6imxn8HCYxhMol6ZnTbSsFW6VZjFMjQIDAQABo4HaMIHXMB0GA1UdDgQWBBTx0lDzjH/iOBnOSQaSEWQLx1syGDCBpwYDVR0jBIGfMIGcgBTx0lDzjH/iOBnOSQaSEWQLx1syGKGBgKR+MHwxCzAJBgNVBAYTAmF3MQ4wDAYDVQQIEwVhcnViYTEOMAwGA1UEChMFYXJ1YmExDjAMBgNVBAcTBWFydWJhMQ4wDAYDVQQLEwVhcnViYTEOMAwGA1UEAxMFYXJ1YmExHTAbBgkqhkiG9w0BCQEWDmFydWJhQGFydWJhLmFyggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADgYEAYvBJ0HOZbbHClXmGUjGs+GS+xC1FO/am2suCSYqNB9dyMXfOWiJ1+TLJk+o/YZt8vuxCKdcZYgl4l/L6PxJ982SRhc83ZW2dkAZI4M0/Ud3oePe84k8jm3A7EvH5wi5hvCkKRpuRBwn3Ei+jCRouxTbzKPsuCVB+1sNyxMTXzf0="), + xpath("/EntityDescriptor/SPSSODescriptor/AssertionConsumerService/@Location").string(containsString("/saml/SSO/alias/%s.integration-saml-entity-id-alias".formatted(zoneSubdomain))) // this needs to be: /saml/SSO/alias/[zone-subdomain].[UAA-wide SAML entity ID, aka UAA.yml's login.saml.entityIDAlias, or fall back on login.entityID] + ); + } + } + + private IdentityZone createZone(String zoneSubdomain, UaaClientDetails adminClient, Boolean samlRequestSigned, Boolean samlWantAssertionSigned, String samlZoneEntityID) throws Exception { + IdentityZone identityZone = MultitenancyFixture.identityZone(zoneSubdomain, zoneSubdomain); + identityZone.getConfig().getSamlConfig().setRequestSigned(samlRequestSigned); + identityZone.getConfig().getSamlConfig().setWantAssertionSigned(samlWantAssertionSigned); + identityZone.getConfig().getSamlConfig().setEntityID(samlZoneEntityID); + return MockMvcUtils.createOtherIdentityZoneAndReturnResult(mockMvc, webApplicationContext, adminClient, identityZone, true, IdentityZoneHolder.getCurrentZoneId()).getIdentityZone(); } } \ No newline at end of file