diff --git a/pom.xml b/pom.xml index c048f71..23a6903 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ 4.0.0 com.github.lscorcia keycloak-spid-provider - 25.0.1 + 25.0.1.2 jar Keycloak SPID Service Provider @@ -20,6 +20,7 @@ UTF-8 25.0.1 + 3.8.5 1.7.30 5.8.2 4.3.1 diff --git a/src/main/java/org/keycloak/broker/spid/SpidSAMLEndpoint.java b/src/main/java/org/keycloak/broker/spid/SpidSAMLEndpoint.java index ff770d8..823e515 100755 --- a/src/main/java/org/keycloak/broker/spid/SpidSAMLEndpoint.java +++ b/src/main/java/org/keycloak/broker/spid/SpidSAMLEndpoint.java @@ -18,7 +18,6 @@ package org.keycloak.broker.spid; import org.jboss.logging.Logger; -//import org.jboss.resteasy.reactive.NoCache; import org.keycloak.broker.provider.BrokeredIdentityContext; import org.keycloak.broker.provider.IdentityBrokerException; @@ -179,7 +178,6 @@ public SpidSAMLEndpoint(KeycloakSession session, SpidIdentityProvider provider, } @GET - // @NoCache @Path("descriptor") public Response getSPDescriptor() { return provider.export(session.getContext().getUri(), realm, null); diff --git a/src/test/java/org/keycloak/broker/spid/metadata/SpidSpMetadataResourceProviderTest.java b/src/test/java/org/keycloak/broker/spid/metadata/SpidSpMetadataResourceProviderTest.java new file mode 100644 index 0000000..d08ca8e --- /dev/null +++ b/src/test/java/org/keycloak/broker/spid/metadata/SpidSpMetadataResourceProviderTest.java @@ -0,0 +1,311 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * 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 + * + * http://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.keycloak.broker.spid.metadata; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.function.Executable; +import org.keycloak.broker.provider.IdentityProviderMapper; +import org.keycloak.broker.saml.SAMLIdentityProviderConfig; +import org.keycloak.broker.spid.SpidIdentityProviderConfig; +import org.keycloak.broker.spid.SpidIdentityProviderFactory; +import org.keycloak.broker.spid.mappers.SpidUserAttributeMapper; +import org.keycloak.common.crypto.CryptoIntegration; +import org.keycloak.common.util.KeyUtils; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.models.IdentityProviderMapperModel; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeyManager; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.KeycloakUriInfo; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.saml.SamlPrincipalType; +import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmlunit.builder.DiffBuilder; +import org.xmlunit.builder.Input; +import org.xmlunit.diff.Diff; +import org.xmlunit.placeholder.PlaceholderDifferenceEvaluator; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; + +import javax.xml.transform.Source; +import java.net.URI; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class SpidSpMetadataResourceProviderTest { + + private static final transient Logger log = LoggerFactory.getLogger(SpidSpMetadataResourceProviderTest.class); + private static final String SP_KEYCLOAK_BASE_URL = "https://keycloak.company.name.it"; + private static KeyWrapper keyWrapper; + @Mock + private KeycloakSession keycloakSession; + @Mock + private KeycloakSessionFactory keycloakSessionFactory; + @Mock + private RealmModel realm; + @InjectMocks + private SpidSpMetadataResourceProvider invitationResourceProvider = spy(new SpidSpMetadataResourceProvider(keycloakSession)); + + @BeforeAll + public static void setupKeyWrapper() throws NoSuchAlgorithmException, CertificateEncodingException, SignatureException, NoSuchProviderException, InvalidKeyException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(1024); + KeyPair keyPair = keyGen.generateKeyPair(); + keyWrapper = new KeyWrapper(); + keyWrapper.setAlgorithm(Algorithm.RS256); + keyWrapper.setKid(KeyUtils.createKeyId(keyPair.getPublic())); + keyWrapper.setPrivateKey(keyPair.getPrivate()); + keyWrapper.setPublicKey(keyPair.getPublic()); + keyWrapper.setCertificate(generateCertificate(keyPair)); + } + + private static X509Certificate generateCertificate(final KeyPair keyPair) throws CertificateEncodingException, NoSuchAlgorithmException, SignatureException, NoSuchProviderException, InvalidKeyException { + CryptoIntegration.init(SpidSpMetadataResourceProviderTest.class.getClassLoader()); + + return CryptoIntegration.getProvider().getCertificateUtils() + .createServicesTestCertificate("CN=Example_CN", + new Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000), + new Date(System.currentTimeMillis() + 2 * 365 * 24 * 60 * 60 * 1000), + keyPair); + } + + @BeforeEach + public void mockKeycloak() { + try { + KeycloakContext keycloakContext = mock(KeycloakContext.class); + when(keycloakSession.getContext()).thenReturn(keycloakContext); + lenient().when(keycloakSession.getKeycloakSessionFactory()).thenReturn(keycloakSessionFactory); + KeycloakUriInfo keycloakUriInfo = mock(KeycloakUriInfo.class); + lenient().when(keycloakUriInfo.getBaseUriBuilder()).thenAnswer(i -> UriBuilder.fromUri(new URI(SP_KEYCLOAK_BASE_URL + "/auth"))); + lenient().when(keycloakContext.getUri()).thenReturn(keycloakUriInfo); + when(keycloakContext.getRealm()).thenReturn(realm); + lenient().when(realm.getName()).thenReturn("spid-realm"); + // Mock keys + KeyManager keyManager = mock(KeyManager.class); + lenient().when(keycloakSession.keys()).thenReturn(keyManager); + lenient().when(keyManager.getKeysStream(realm, KeyUse.SIG, Algorithm.RS256)).thenReturn(Stream.of(keyWrapper)); + lenient().when(keyManager.getActiveRsaKey(realm)).thenReturn( + new KeyManager.ActiveRsaKey(keyWrapper.getKid(), (PrivateKey) keyWrapper.getPrivateKey(), (PublicKey) keyWrapper.getPublicKey(), + keyWrapper.getCertificate())); + } catch (Exception e) { + log.error("", e); + } + } + + @Test + void get_withoutSPIDIdentityProviders_shouldThrowException() { + mockSPIDProviders(null); + + RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> { + invitationResourceProvider.get(); + }); + + assertEquals("java.lang.Exception: No SPID providers found!", runtimeException.getMessage()); + assertNotNull(runtimeException.getCause()); + assertEquals("No SPID providers found!", runtimeException.getCause().getMessage()); + } + + @Test + void get_withPublicSPConfiguration_shouldReturnExpectXml() { + mockSPIDProviders(mockPublicSPConfig(), "idp1", "idp2"); + + Response response = invitationResourceProvider.get(); + assertEquals(200, response.getStatus()); + assertMetaData(response.getEntity().toString(), "/metadata/expected_metadata_public_SP.xml"); + } + + @Test + void get_withPrivateSPConfiguration_shouldReturnExpectXml() { + mockSPIDProviders(mockPrivateSPConfig(), "idp1", "idp2"); + + Response response = invitationResourceProvider.get(); + assertEquals(200, response.getStatus()); + assertMetaData(response.getEntity().toString(), "/metadata/expected_metadata_private_SP.xml"); + } + + private Map mockPublicSPConfig() { + Map providerConfig = mockCommonConfig(); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_SP_PRIVATE, "false"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_IPA_CODE, "IPA_manager"); + + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_COMPANY, "Public Company Name"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_PHONE, "+39 123 456 789"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_EMAIL, "other_contact@domain.test"); + + return providerConfig; + } + + private Map mockPrivateSPConfig() { + Map providerConfig = mockCommonConfig(); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_SP_PRIVATE, "true"); + + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_VAT_NUMBER, "IT01234567890"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_FISCAL_CODE, "CF_manager"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_COMPANY, "Private Company Name"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_PHONE, "+39 123 456 789"); + providerConfig.put(SpidIdentityProviderConfig.OTHER_CONTACT_EMAIL, "other_contact@domain.test"); + + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_COMPANY, "Billing contact company"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_PHONE, "+39 987 654 321"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_EMAIL, "billing@domain.test"); + + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_REGISTRY_NAME, "Registry Name"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_SITE_ADDRESS, "StreetName"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_SITE_NUMBER, "111"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_SITE_CITY, "City"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_SITE_ZIP_CODE, "zip"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_SITE_PROVINCE, "Province"); + providerConfig.put(SpidIdentityProviderConfig.BILLING_CONTACT_SITE_COUNTRY, "IT"); + + return providerConfig; + } + + private Map mockCommonConfig() { + Map providerConfig = new HashMap(); + // Generic SAML configuration options + + providerConfig.put(SAMLIdentityProviderConfig.ENTITY_ID, SP_KEYCLOAK_BASE_URL); + providerConfig.put(SAMLIdentityProviderConfig.BACKCHANNEL_SUPPORTED, "false"); + providerConfig.put(SAMLIdentityProviderConfig.NAME_ID_POLICY_FORMAT, "Transient"); + providerConfig.put(SAMLIdentityProviderConfig.PRINCIPAL_TYPE, SamlPrincipalType.ATTRIBUTE.toString()); + providerConfig.put(SAMLIdentityProviderConfig.PRINCIPAL_ATTRIBUTE, "fiscalNumber"); + providerConfig.put(SAMLIdentityProviderConfig.ALLOW_CREATE, "true"); + providerConfig.put(SAMLIdentityProviderConfig.POST_BINDING_RESPONSE, "true"); + providerConfig.put(SAMLIdentityProviderConfig.POST_BINDING_AUTHN_REQUEST, "true"); + providerConfig.put(SAMLIdentityProviderConfig.POST_BINDING_LOGOUT, "true"); + providerConfig.put(SAMLIdentityProviderConfig.WANT_AUTHN_REQUESTS_SIGNED, "true"); + providerConfig.put(SAMLIdentityProviderConfig.WANT_ASSERTIONS_SIGNED, "true"); + providerConfig.put(SAMLIdentityProviderConfig.WANT_ASSERTIONS_ENCRYPTED, "false"); + providerConfig.put(SAMLIdentityProviderConfig.SIGNATURE_ALGORITHM, "RSA_SHA256"); + providerConfig.put(SAMLIdentityProviderConfig.XML_SIG_KEY_INFO_KEY_NAME_TRANSFORMER, XmlKeyInfoKeyNameTransformer.NONE.toString()); + providerConfig.put(SAMLIdentityProviderConfig.FORCE_AUTHN, "false"); + providerConfig.put(SAMLIdentityProviderConfig.VALIDATE_SIGNATURE, "true"); + providerConfig.put(SAMLIdentityProviderConfig.SIGNING_CERTIFICATE_KEY, keyWrapper.getKid()); + providerConfig.put(SAMLIdentityProviderConfig.SIGN_SP_METADATA, "true"); + providerConfig.put(SAMLIdentityProviderConfig.LOGIN_HINT, "false"); // Pass subject + providerConfig.put(SAMLIdentityProviderConfig.ALLOWED_CLOCK_SKEW, ""); + providerConfig.put(SAMLIdentityProviderConfig.ATTRIBUTE_CONSUMING_SERVICE_INDEX, "1"); + providerConfig.put(SAMLIdentityProviderConfig.ATTRIBUTE_CONSUMING_SERVICE_NAME, "en|Online services,it|Servizi online"); + + // SPID specific configuration + providerConfig.put(SpidIdentityProviderConfig.ORGANIZATION_NAMES, "en|MyCompany srl,it|MyCompany srl"); + providerConfig.put(SpidIdentityProviderConfig.ORGANIZATION_DISPLAY_NAMES, "en|MyCompany,it|MyCompany"); + providerConfig.put(SpidIdentityProviderConfig.ORGANIZATION_URLS, "en|https://company.name.it,it|https://company.name.it"); + return providerConfig; + } + + private void mockSPIDProviders(Map commonConfig, String... aliases) { + when(realm.getIdentityProvidersStream()).thenReturn(Stream.of(aliases).map(alias -> mockSPIDProvider(commonConfig, alias))); + } + + private IdentityProviderModel mockSPIDProvider(Map commonConfig, String alias) { + IdentityProviderModel idpModel = mock(IdentityProviderModel.class); + when(idpModel.getAlias()).thenReturn(alias); + when(idpModel.getProviderId()).thenReturn(SpidIdentityProviderFactory.PROVIDER_ID); + when(idpModel.isEnabled()).thenReturn(true); + Map idpConfig = new HashMap(); + idpConfig.putAll(commonConfig); + idpConfig.put(SAMLIdentityProviderConfig.SINGLE_SIGN_ON_SERVICE_URL, "https://" + alias + ".localtest.me/samlsso/login"); + idpConfig.put(SAMLIdentityProviderConfig.SINGLE_LOGOUT_SERVICE_URL, "https://" + alias + ".localtest.me/samlsso/logout"); + lenient().when(idpModel.getConfig()).thenReturn(idpConfig); + Stream identityProviderMappers = mockAttributeMappers(alias); + lenient().when(realm.getIdentityProviderMappersByAliasStream(alias)).thenReturn(identityProviderMappers); + return idpModel; + } + + private Stream mockAttributeMappers(String alias) { + IdentityProviderMapperModel taxIdMapper = mockSpidUserAttributeMapper(alias, "Tax Id", "fiscalNumber"); + IdentityProviderMapperModel firstNameMapper = mockSpidUserAttributeMapper(alias, "First Name", "name"); + IdentityProviderMapperModel lastNameMapper = mockSpidUserAttributeMapper(alias, "Last Name", "familyName"); + return Stream.of(taxIdMapper, firstNameMapper, lastNameMapper); + } + + private IdentityProviderMapperModel mockSpidUserAttributeMapper(final String alias, final String name, final String attributeName) { + IdentityProviderMapperModel spidUserAttributeMapper = new IdentityProviderMapperModel(); + spidUserAttributeMapper.setId(UUID.randomUUID().toString()); + spidUserAttributeMapper.setName(name); + spidUserAttributeMapper.setIdentityProviderAlias(alias); + spidUserAttributeMapper.setIdentityProviderMapper(alias + "_" + name); + Map config = new HashMap<>(); + config.put("attribute.name", attributeName); + config.put("attribute.friendly.name", ""); + spidUserAttributeMapper.setConfig(config); + lenient().when(keycloakSessionFactory.getProviderFactory(IdentityProviderMapper.class, alias + "_" + name)).thenReturn(new SpidUserAttributeMapper()); + return spidUserAttributeMapper; + } + + private void assertMetaData(String response, String expectedResource) { + Source responseMetadata = Input.fromString(response).build(); + Source control = Input.fromStream(this.getClass().getResourceAsStream(expectedResource)).build(); + + Diff myDiff = DiffBuilder.compare(control) + .withTest(responseMetadata) + .checkForIdentical() + .ignoreComments() + .ignoreWhitespace() + .normalizeWhitespace() + .withDifferenceEvaluator(new PlaceholderDifferenceEvaluator()) + .build(); + + Assertions.assertAll("Found differences in metadata file", + StreamSupport.stream(myDiff.getDifferences().spliterator(), false) + .map(diff -> (Executable) (() -> fail(diff.getComparison().toString()))) + .collect(Collectors.toList())); + + } +} +