Skip to content

Commit

Permalink
WIP: per-zone SAML SP metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
peterhaochen47 committed Jul 9, 2024
1 parent 70019ee commit 290f89c
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public class IdentityZoneConfigurationBootstrap implements InitializingBean {
private String samlSpPrivateKeyPassphrase;
private String samlSpCertificate;
private boolean disableSamlInResponseToCheck = false;
private boolean samlWantAssertionSigned = true;
private boolean samlRequestSigned = true;

private Map<String, Map<String, String>> samlKeys;
private String activeKeyId;
Expand Down Expand Up @@ -89,6 +91,8 @@ public void afterPropertiesSet() throws InvalidIdentityZoneDetailsException {
definition.getSamlConfig().setPrivateKey(samlSpPrivateKey);
definition.getSamlConfig().setPrivateKeyPassword(samlSpPrivateKeyPassphrase);
definition.getSamlConfig().setDisableInResponseToCheck(disableSamlInResponseToCheck);
definition.getSamlConfig().setWantAssertionSigned(samlWantAssertionSigned);
definition.getSamlConfig().setRequestSigned(samlRequestSigned);
definition.setIdpDiscoveryEnabled(idpDiscoveryEnabled);
definition.setAccountChooserEnabled(accountChooserEnabled);
definition.setDefaultIdentityProvider(defaultIdentityProvider);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package org.cloudfoundry.identity.uaa.provider.saml;

import org.cloudfoundry.identity.uaa.zone.IdentityZone;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.cloudfoundry.identity.uaa.zone.SamlConfig;
import org.cloudfoundry.identity.uaa.zone.ZoneAware;
import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.SPSSODescriptor;
Expand Down Expand Up @@ -34,31 +38,33 @@ public class SamlMetadataEndpoint implements ZoneAware {
// @todo - this should be a Zone aware resolver
private final RelyingPartyRegistrationResolver relyingPartyRegistrationResolver;
private final Saml2MetadataResolver saml2MetadataResolver;
private final IdentityZoneManager identityZoneManager;

private String fileName;
private String encodedFileName;

private final Boolean wantAssertionSigned;
private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;

public SamlMetadataEndpoint(RelyingPartyRegistrationRepository relyingPartyRegistrationRepository,
SamlConfigProps samlConfigProps) {
IdentityZoneManager identityZoneManager) {
Assert.notNull(relyingPartyRegistrationRepository, "relyingPartyRegistrationRepository cannot be null");
this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
this.relyingPartyRegistrationResolver = new DefaultRelyingPartyRegistrationResolver(relyingPartyRegistrationRepository);
this.identityZoneManager = identityZoneManager;
OpenSamlMetadataResolver resolver = new OpenSamlMetadataResolver();
this.saml2MetadataResolver = resolver;
resolver.setEntityDescriptorCustomizer(new EntityDescriptorCustomizer());
this.wantAssertionSigned = samlConfigProps.getWantAssertionSigned();
setFileName(DEFAULT_FILE_NAME);
}

private class EntityDescriptorCustomizer implements Consumer<OpenSamlMetadataResolver.EntityDescriptorParameters> {
@Override
public void accept(OpenSamlMetadataResolver.EntityDescriptorParameters entityDescriptorParameters) {
SamlConfig samlConfig = identityZoneManager.getCurrentIdentityZone().getConfig().getSamlConfig();

EntityDescriptor descriptor = entityDescriptorParameters.getEntityDescriptor();
SPSSODescriptor spssodescriptor = descriptor.getSPSSODescriptor(SAMLConstants.SAML20P_NS);
spssodescriptor.setWantAssertionsSigned(wantAssertionSigned);
spssodescriptor.setWantAssertionsSigned(samlConfig.isWantAssertionSigned());
spssodescriptor.setAuthnRequestsSigned(entityDescriptorParameters.getRelyingPartyRegistration().getAssertingPartyDetails().getWantAuthnRequestsSigned());
}
}
Expand All @@ -76,11 +82,10 @@ public ResponseEntity<String> metadataEndpoint(@PathVariable String registration
}
String metadata = saml2MetadataResolver.resolve(relyingPartyRegistration);

// @todo - fileName may need to be dynamic based on registrationID
String[] fileNames = retrieveZoneAwareFileNames();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(
CONTENT_DISPOSITION_FORMAT, fileNames[0], fileNames[1]))
.header(HttpHeaders.CONTENT_DISPOSITION,
CONTENT_DISPOSITION_FORMAT.formatted(fileNames[0], fileNames[1]))
.body(metadata);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@ void defaultSamlKeys() throws Exception {
assertThat(uaa.getConfig().getSamlConfig().getCertificate()).isEqualTo(SamlTestUtils.PROVIDER_CERTIFICATE);
}

@Test
void samlWantAssertionSigned() throws Exception {
bootstrap.setSamlSpPrivateKey(SamlTestUtils.PROVIDER_PRIVATE_KEY);
bootstrap.setSamlSpCertificate(SamlTestUtils.PROVIDER_CERTIFICATE);
bootstrap.setSamlSpPrivateKeyPassphrase(SamlTestUtils.PROVIDER_PRIVATE_KEY_PASSWORD);
bootstrap.setSamlWantAssertionSigned(false);
bootstrap.setSamlRequestSigned(false);
bootstrap.afterPropertiesSet();
IdentityZone uaa = provisioning.retrieve(IdentityZone.getUaaZoneId());
assertThat(uaa.getConfig().getSamlConfig().isWantAssertionSigned()).isEqualTo(false);
assertThat(uaa.getConfig().getSamlConfig().isRequestSigned()).isEqualTo(false);
}

@Test
void enableInResponseTo() throws Exception {
bootstrap.setDisableSamlInResponseToCheck(false);
Expand Down Expand Up @@ -253,7 +266,6 @@ void logoutRedirect() throws Exception {
assertThat(config.getLinks().getLogout().isDisableRedirectParameter()).isFalse();
}


@Test
void testPrompts() throws Exception {
List<Prompt> prompts = Arrays.asList(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.cloudfoundry.identity.uaa.provider.saml;

import org.cloudfoundry.identity.uaa.zone.IdentityZone;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration;
import org.cloudfoundry.identity.uaa.zone.SamlConfig;
import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager;
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.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;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.cloudfoundry.identity.uaa.provider.saml.TestSaml2X509Credentials.relyingPartySigningCredential;
import static org.cloudfoundry.identity.uaa.provider.saml.TestSaml2X509Credentials.relyingPartyVerifyingCredential;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class SamlMetadataEndpointTest {
SamlMetadataEndpoint endpoint;

MockHttpServletRequest request;

@Mock
RelyingPartyRegistrationRepository repository;
@Mock
IdentityZoneManager identityZoneManager;
@Mock
RelyingPartyRegistration registration;
@Mock
IdentityZone identityZone;
@Mock
IdentityZoneConfiguration identityZoneConfiguration;
@Mock
SamlConfig samlConfig;
@Mock
RelyingPartyRegistration.AssertingPartyDetails assertingPartyDetails;

@BeforeEach
void setUp() {
endpoint = spy(new SamlMetadataEndpoint(repository, identityZoneManager));
when(repository.findByRegistrationId("regId")).thenReturn(registration);
when(registration.getEntityId()).thenReturn("entityId");
when(registration.getSigningX509Credentials()).thenReturn(List.of(relyingPartySigningCredential()));
when(registration.getDecryptionX509Credentials()).thenReturn(List.of(relyingPartyVerifyingCredential()));
when(registration.getAssertionConsumerServiceBinding()).thenReturn(Saml2MessageBinding.REDIRECT);
when(registration.getAssertionConsumerServiceLocation()).thenReturn("acsl");
when(identityZoneManager.getCurrentIdentityZone()).thenReturn(identityZone);
when(identityZone.getConfig()).thenReturn(identityZoneConfiguration);
when(identityZoneConfiguration.getSamlConfig()).thenReturn(samlConfig);
when(registration.getAssertingPartyDetails()).thenReturn(assertingPartyDetails);
}

@Test
void testDefaultFileName() {
ResponseEntity<String> response = endpoint.metadataEndpoint("regId", request);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.isEqualTo("attachment; filename=\"saml-sp.xml\"; filename*=UTF-8''saml-sp.xml");
}

@Test
void testZonedFileName() {
when(identityZone.isUaa()).thenReturn(false);
when(identityZone.getSubdomain()).thenReturn("testzone1");
when(endpoint.retrieveZone()).thenReturn(identityZone);
ResponseEntity<String> response = endpoint.metadataEndpoint("regId", request);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.isEqualTo("attachment; filename=\"saml-testzone1-sp.xml\"; filename*=UTF-8''saml-testzone1-sp.xml");
}
}
2 changes: 2 additions & 0 deletions uaa/src/main/webapp/WEB-INF/spring-servlet.xml
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@
@config['login']['saml']==null ? null :
@config['login']['saml']['keys']}"/>
<property name="disableSamlInResponseToCheck" value="${login.saml.disableInResponseToCheck:false}"/>
<property name="samlWantAssertionSigned" value="${login.saml.wantAssertionSigned:true}"/>
<property name="samlRequestSigned" value="${login.saml.signRequest:true}"/>
<property name="defaultUserGroups" ref="defaultUserAuthorities"/>
<property name="defaultIdentityProvider" value="${login.defaultIdentityProvider:#{null}}"/>
</bean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
import org.cloudfoundry.identity.uaa.zone.IdentityZone;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration;
import org.flywaydb.core.internal.util.StringUtils;
import org.hamcrest.Matchers;
import org.junit.Rule;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
Expand All @@ -76,6 +75,7 @@
import org.springframework.util.FileCopyUtils;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
import org.xmlunit.assertj.XmlAssert;

import java.io.IOException;
import java.io.InputStreamReader;
Expand Down Expand Up @@ -107,10 +107,9 @@
import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.EMAIL_ATTRIBUTE_NAME;
import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.GROUP_ATTRIBUTE_NAME;
import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.USER_ATTRIBUTE_PREFIX;
import static org.cloudfoundry.identity.uaa.provider.saml.Saml2TestUtils.xmlNamespaces;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
Expand Down Expand Up @@ -157,7 +156,9 @@ public class SamlLoginIT {

@BeforeAll
static void checkZoneDNSSupport() {
assertTrue(doesSupportZoneDNS(), "Expected testzone1.localhost, testzone2.localhost, testzone3.localhost, testzone4.localhost to resolve to 127.0.0.1");
assertThat(doesSupportZoneDNS())
.as("Expected testzone1.localhost, testzone2.localhost, testzone3.localhost, testzone4.localhost to resolve to 127.0.0.1")
.isTrue();
}

public static String getValidRandomIDPMetaData() {
Expand Down Expand Up @@ -216,23 +217,26 @@ void samlSPMetadata() {
"%s/saml/metadata".formatted(baseUrl), String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String metadataXml = response.getBody();
XmlAssert xmlAssert = XmlAssert.assertThat(metadataXml).withNamespaceContext(xmlNamespaces());

// The SAML SP metadata should match the following UAA configs:
// login.entityID
assertThat(metadataXml).contains("entityID=\"cloudfoundry-saml-login\"")
// TODO: Are DigestMethod and SignatureMethod needed?
// login.saml.signatureAlgorithm
//.contains("<ds:DigestMethod Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\"/>")
//.contains("<ds:SignatureMethod Algorithm=\"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256\"/>")
// login.saml.signRequest
.contains("AuthnRequestsSigned=\"true\"")
// login.saml.wantAssertionSigned
.contains("WantAssertionsSigned=\"true\"")
// login.saml.nameID
.contains("<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>");

assertEquals("saml-sp.xml",
response.getHeaders().getContentDisposition().getFilename());
xmlAssert.valueByXPath("//md:EntityDescriptor/@entityID").isEqualTo("cloudfoundry-saml-login");
// login.saml.signRequest
xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/@AuthnRequestsSigned").isEqualTo(true);
// login.saml.wantAssertionSigned
xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/@WantAssertionsSigned").isEqualTo(true);
xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/md:AssertionConsumerService/@Location").contains("/saml/SSO/alias/cloudfoundry-saml-login");

// assertThat(metadataXml).contains("entityID=\"cloudfoundry-saml-login\"")
// // TODO: Are DigestMethod and SignatureMethod needed?
// // login.saml.signatureAlgorithm
// //.contains("<ds:DigestMethod Algorithm=\"http://www.w3.org/2001/04/xmlenc#sha256\"/>")
// //.contains("<ds:SignatureMethod Algorithm=\"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256\"/>")
// // login.saml.nameID
// .contains("<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>");

assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo("saml-sp.xml");
}

@Test
Expand All @@ -252,24 +256,27 @@ void samlSPMetadataForZone() {
IdentityZoneConfiguration config = new IdentityZoneConfiguration();
config.getCorsPolicy().getDefaultConfiguration().setAllowedMethods(List.of(GET.toString(), POST.toString()));
config.getSamlConfig().setEntityID(zoneId + "-saml-login");
config.getSamlConfig().setWantAssertionSigned(false);
config.getSamlConfig().setRequestSigned(false);
IntegrationTestUtils.createZoneOrUpdateSubdomain(identityClient, baseUrl, zoneId, zoneId, config);

RestTemplate request = new RestTemplate();
ResponseEntity<String> response = request.getForEntity(
zoneUrl + "/saml/metadata", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
String metadataXml = response.getBody();
XmlAssert xmlAssert = XmlAssert.assertThat(metadataXml).withNamespaceContext(xmlNamespaces());

// The SAML SP metadata should match the following UAA configs:
// login.entityID
assertThat(metadataXml).contains("entityID=\"" + zoneId + "-saml-login\"")
.contains("AuthnRequestsSigned=\"true\"")
.contains("WantAssertionsSigned=\"true\"")
// TODO: Improve this check
.contains("/saml/SSO/alias/" + zoneId + ".cloudfoundry-saml-login");

assertEquals("saml-" + zoneId + "-sp.xml",
response.getHeaders().getContentDisposition().getFilename());
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 TODO this is failing because our code only reads the UAA.yml: https://github.com/cloudfoundry/uaa/blob/3e9f1e78b6b60277fb23d952af8d68c9431d15fd/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlRelyingPartyRegistrationRepositoryConfig.java#L41
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
xmlAssert.valueByXPath("//md:EntityDescriptor/md:SPSSODescriptor/@WantAssertionsSigned").isEqualTo(false);
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");
}

@Test
Expand Down Expand Up @@ -491,7 +498,7 @@ void singleLogoutWithNoLogoutUrlOnIDPWithLogoutRedirect() {
IntegrationTestUtils.createOrUpdateProvider(zoneAdminToken, baseUrl, provider);

LoginPage loginPage = LoginPage.go(webDriver, zoneUrl);
loginPage.validateTitle(Matchers.containsString("testzone2"));
loginPage.validateTitle(containsString("testzone2"));
loginPage.clickSamlLink_goesToSamlLoginPage("simplesamlphp")
.login_goesToHomePage(testAccounts.getUserName(), testAccounts.getPassword());

Expand Down

0 comments on commit 290f89c

Please sign in to comment.