diff --git a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle index 903c94fc63..c10aadf708 100644 --- a/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle +++ b/saml2/saml2-service-provider/spring-security-saml2-service-provider.gradle @@ -9,4 +9,6 @@ dependencies { compile("org.opensaml:opensaml-saml-impl") provided 'javax.servlet:javax.servlet-api' + + testCompile 'com.squareup.okhttp3:mockwebserver' } diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java index 97ee941fe4..77c3e0c988 100644 --- a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter.java @@ -149,6 +149,13 @@ public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter encryption.add(encryption(certificate)); } } + if (keyDescriptor.getUse().equals(UsageType.UNSPECIFIED)) { + List certificates = certificates(keyDescriptor); + for (X509Certificate certificate : certificates) { + verification.add(verification(certificate)); + encryption.add(encryption(certificate)); + } + } } if (verification.isEmpty()) { throw new Saml2Exception("Metadata response is missing verification certificates, necessary for verifying SAML assertions"); diff --git a/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrations.java b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrations.java new file mode 100644 index 0000000000..29b9363afb --- /dev/null +++ b/saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrations.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.registration; + +import java.util.Arrays; + +import org.springframework.security.saml2.Saml2Exception; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +/** + * A utility class for constructing instances of {@link RelyingPartyRegistration} + * + * @author Josh Cummings + * @since 5.4 + */ +public final class RelyingPartyRegistrations { + private static final RestOperations rest = new RestTemplate + (Arrays.asList(new OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverter())); + + /** + * Return a {@link RelyingPartyRegistration.Builder} based off of the given + * SAML 2.0 Asserting Party (IDP) metadata. + * + * Note that by default the registrationId is set to be the given metadata location, + * but this will most often not be sufficient. To complete the configuration, most + * applications will also need to provide a registrationId, like so: + * + *
+	 *	RelyingPartyRegistration registration = RelyingPartyRegistrations
+	 * 		.fromMetadataLocation(metadataLocation)
+	 * 		.registrationId("registration-id")
+	 * 		.build();
+	 * 
+ * + * Also note that an {@code IDPSSODescriptor} typically only contains information about + * the asserting party. Thus, you will need to remember to still populate anything about the + * relying party, like any private keys the relying party will use for signing AuthnRequests. + * + * @param metadataLocation + * @return the {@link RelyingPartyRegistration.Builder} for further configuration + */ + public static RelyingPartyRegistration.Builder fromMetadataLocation(String metadataLocation) { + try { + return rest.getForObject(metadataLocation, RelyingPartyRegistration.Builder.class); + } catch (RestClientException e) { + if (e.getCause() instanceof Saml2Exception) { + throw (Saml2Exception) e.getCause(); + } + throw new Saml2Exception(e); + } + } +} diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java index 270183c17b..e9a85b8a01 100644 --- a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests.java @@ -47,7 +47,7 @@ public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests { "%s\n" + ""; private static final String KEY_DESCRIPTOR_TEMPLATE = - "\n" + + "\n" + "\n" + "\n" + "" + CERTIFICATE + "\n" + @@ -88,7 +88,7 @@ public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests { public void readWhenMissingSingleSignOnServiceThenException() { String payload = String.format(ENTITY_DESCRIPTOR_TEMPLATE, String.format(IDP_SSO_DESCRIPTOR_TEMPLATE, - String.format(KEY_DESCRIPTOR_TEMPLATE, "signing") + String.format(KEY_DESCRIPTOR_TEMPLATE, "use=\"signing\"") )); MockClientHttpResponse response = new MockClientHttpResponse(payload.getBytes(), OK); assertThatCode(() -> this.converter.read(RelyingPartyRegistration.Builder.class, response)) @@ -100,8 +100,8 @@ public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests { public void readWhenDescriptorFullySpecifiedThenConfigures() throws Exception { String payload = String.format(ENTITY_DESCRIPTOR_TEMPLATE, String.format(IDP_SSO_DESCRIPTOR_TEMPLATE, - String.format(KEY_DESCRIPTOR_TEMPLATE, "signing") + - String.format(KEY_DESCRIPTOR_TEMPLATE, "encryption") + + String.format(KEY_DESCRIPTOR_TEMPLATE, "use=\"signing\"") + + String.format(KEY_DESCRIPTOR_TEMPLATE, "use=\"encryption\"") + String.format(SINGLE_SIGN_ON_SERVICE_TEMPLATE) )); MockClientHttpResponse response = new MockClientHttpResponse(payload.getBytes(), OK); @@ -123,6 +123,27 @@ public class OpenSamlRelyingPartyRegistrationBuilderHttpMessageConverterTests { .isEqualTo(x509Certificate(CERTIFICATE)); } + @Test + public void readWhenKeyDescriptorHasNoUseThenConfiguresBothKeyTypes() throws Exception { + String payload = String.format(ENTITY_DESCRIPTOR_TEMPLATE, + String.format(IDP_SSO_DESCRIPTOR_TEMPLATE, + String.format(KEY_DESCRIPTOR_TEMPLATE, "") + + String.format(SINGLE_SIGN_ON_SERVICE_TEMPLATE) + )); + MockClientHttpResponse response = new MockClientHttpResponse(payload.getBytes(), OK); + RelyingPartyRegistration registration = + this.converter.read(RelyingPartyRegistration.Builder.class, response) + .registrationId("one") + .build(); + RelyingPartyRegistration.AssertingPartyDetails details = + registration.getAssertingPartyDetails(); + assertThat(details.getVerificationX509Credentials().iterator().next().getCertificate()) + .isEqualTo(x509Certificate(CERTIFICATE)); + assertThat(details.getEncryptionX509Credentials()).hasSize(1); + assertThat(details.getEncryptionX509Credentials().iterator().next().getCertificate()) + .isEqualTo(x509Certificate(CERTIFICATE)); + } + X509Certificate x509Certificate(String data) { try { InputStream certificate = new ByteArrayInputStream(Base64.getDecoder().decode(data.getBytes())); diff --git a/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationsTests.java b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationsTests.java new file mode 100644 index 0000000000..c3dfaed2b2 --- /dev/null +++ b/saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/registration/RelyingPartyRegistrationsTests.java @@ -0,0 +1,159 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.saml2.provider.service.registration; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Test; + +import org.springframework.security.saml2.Saml2Exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; + +/** + * Tests for {@link RelyingPartyRegistration} + */ +public class RelyingPartyRegistrationsTests { + private static final String IDP_SSO_DESCRIPTOR_PAYLOAD = + "\n" + + " \n" + + " \n" + + " \n" + + " example.com\n" + + " \n" + + " \n" + + " \n" + + " Consortium GARR IdP\n" + + " \n" + + " \n" + + " Consortium GARR IdP\n" + + " \n" + + " \n" + + " \n" + + " This Identity Provider gives support for the Consortium GARR's user community\n" + + " \n" + + " \n" + + " Questo Identity Provider di test fornisce supporto alla comunita' utenti GARR\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " MIIDZjCCAk6gAwIBAgIVAL9O+PA7SXtlwZZY8MVSE9On1cVWMA0GCSqGSIb3DQEB\n" + + " BQUAMCkxJzAlBgNVBAMTHmlkZW0tcHVwYWdlbnQuZG16LWludC51bmltby5pdDAe\n" + + " Fw0xMzA3MjQwMDQ0MTRaFw0zMzA3MjQwMDQ0MTRaMCkxJzAlBgNVBAMTHmlkZW0t\n" + + " cHVwYWdlbnQuZG16LWludC51bmltby5pdDCCASIwDQYJKoZIhvcNAMIIDQADggEP\n" + + " ADCCAQoCggEBAIAcp/VyzZGXUF99kwj4NvL/Rwv4YvBgLWzpCuoxqHZ/hmBwJtqS\n" + + " v0y9METBPFbgsF3hCISnxbcmNVxf/D0MoeKtw1YPbsUmow/bFe+r72hZ+IVAcejN\n" + + " iDJ7t5oTjsRN1t1SqvVVk6Ryk5AZhpFW+W9pE9N6c7kJ16Rp2/mbtax9OCzxpece\n" + + " byi1eiLfIBmkcRawL/vCc2v6VLI18i6HsNVO3l2yGosKCbuSoGDx2fCdAOk/rgdz\n" + + " cWOvFsIZSKuD+FVbSS/J9GVs7yotsS4PRl4iX9UMnfDnOMfO7bcBgbXtDl4SCU1v\n" + + " dJrRw7IL/pLz34Rv9a8nYitrzrxtLOp3nYUCAwEAAaOBhDCBgTBgBgMIIDEEWTBX\n" + + " gh5pZGVtLXB1cGFnZW50LmRtei1pbnQudW5pbW8uaXSGNWh0dHBzOi8vaWRlbS1w\n" + + " dXBhZ2VudC5kbXotaW50LnVuaW1vLml0L2lkcC9zaGliYm9sZXRoMB0GA1UdDgQW\n" + + " BBT8PANzz+adGnTRe8ldcyxAwe4VnzANBgkqhkiG9w0BAQUFAAOCAQEAOEnO8Clu\n" + + " 9z/Lf/8XOOsTdxJbV29DIF3G8KoQsB3dBsLwPZVEAQIP6ceS32Xaxrl6FMTDDNkL\n" + + " qUvvInUisw0+I5zZwYHybJQCletUWTnz58SC4C9G7FpuXHFZnOGtRcgGD1NOX4UU\n" + + " duus/4nVcGSLhDjszZ70Xtj0gw2Sn46oQPHTJ81QZ3Y9ih+Aj1c9OtUSBwtWZFkU\n" + + " yooAKoR8li68Yb21zN2N65AqV+ndL98M8xUYMKLONuAXStDeoVCipH6PJ09Z5U2p\n" + + " V5p4IQRV6QBsNw9CISJFuHzkVYTH5ZxzN80Ru46vh4y2M0Nu8GQ9I085KoZkrf5e\n" + + " Cq53OZt9ISjHEw==\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " Consortium GARR\n" + + " \n" + + " \n" + + " Consortium GARR\n" + + " \n" + + " \n" + + " \n" + + " Consortium GARR\n" + + " \n" + + " \n" + + " Consortium GARR\n" + + " \n" + + " \n" + + " \n" + + " https://example.org\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " mailto:technical.contact@example.com\n" + + " \n" + + " \n" + + ""; + + @Test + public void fromMetadataLocationWhenResolvableThenPopulatesBuilder() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setBody(IDP_SSO_DESCRIPTOR_PAYLOAD).setResponseCode(200)); + RelyingPartyRegistration registration = RelyingPartyRegistrations + .fromMetadataLocation(server.url("/").toString()) + .entityId("rp") + .build(); + RelyingPartyRegistration.AssertingPartyDetails details = registration.getAssertingPartyDetails(); + assertThat(details.getEntityId()).isEqualTo("https://idp.example.com/idp/shibboleth"); + assertThat(details.getSingleSignOnServiceLocation()) + .isEqualTo("https://idp.example.com/idp/profile/SAML2/POST/SSO"); + assertThat(details.getSingleSignOnServiceBinding()) + .isEqualTo(Saml2MessageBinding.POST); + assertThat(details.getVerificationX509Credentials()).hasSize(1); + assertThat(details.getEncryptionX509Credentials()).hasSize(1); + } + } + + @Test + public void fromMetadataLocationWhenUnresolvableThenSaml2Exception() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setBody(IDP_SSO_DESCRIPTOR_PAYLOAD).setResponseCode(200)); + String url = server.url("/").toString(); + server.shutdown(); + assertThatCode(() -> RelyingPartyRegistrations.fromMetadataLocation(url)) + .isInstanceOf(Saml2Exception.class); + } + } + + @Test + public void fromMetadataLocationWhenMalformedResponseThenSaml2Exception() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setBody("malformed").setResponseCode(200)); + String url = server.url("/").toString(); + assertThatCode(() -> RelyingPartyRegistrations.fromMetadataLocation(url)) + .isInstanceOf(Saml2Exception.class); + } + } +}