From 7f54a572713cc762b764feec11fafc3a6b4e05c0 Mon Sep 17 00:00:00 2001 From: rmartinc Date: Wed, 3 Jul 2019 12:21:32 +0200 Subject: [PATCH] KEYCLOAK-10757: Replaying assertion with signature in SAML adapters --- .../subsystem/saml/as7/Constants.java | 2 + .../saml/as7/ServiceProviderDefinition.java | 7 +- .../saml/as7/LocalDescriptions.properties | 3 +- .../schema/wildfly-keycloak-saml_1_2.xsd | 7 +- ...oak-saml-1.1.xml => keycloak-saml-1.2.xml} | 5 +- .../keycloak/adapters/saml/SamlPrincipal.java | 17 ++++ .../adapters/saml/DefaultSamlDeployment.java | 10 +++ .../adapters/saml/SamlDeployment.java | 2 + .../org/keycloak/adapters/saml/config/SP.java | 9 ++ .../config/parsers/DeploymentBuilder.java | 1 + .../parsers/KeycloakSamlAdapterV1QNames.java | 1 + .../saml/config/parsers/SpParser.java | 1 + .../AbstractSamlAuthenticationHandler.java | 36 +++++++- .../schema/keycloak_saml_adapter_1_12.xsd | 5 ++ .../KeycloakSamlAdapterXMLParserTest.java | 12 +++ .../keycloak-saml-keepdomassertion.xml | 77 ++++++++++++++++++ .../adapter/saml/extension/Constants.java | 2 + .../extension/ServiceProviderDefinition.java | 7 +- .../extension/LocalDescriptions.properties | 3 +- .../schema/wildfly-keycloak-saml_1_2.xsd | 5 ++ .../saml/extension/keycloak-saml-1.1.xml | 71 ---------------- ...-1.1-err.xml => keycloak-saml-1.2-err.xml} | 4 +- .../saml/extension/keycloak-saml-1.2.xml | 3 +- .../KeycloakSamlSubsystemInstallation.java | 2 +- .../adapter/servlet/SendUsernameServlet.java | 28 +++++++ .../adapter/page/EmployeeDomServlet.java | 39 +++++++++ .../servlet/SAMLServletAdapterTest.java | 35 ++++++++ .../employee-dom/WEB-INF/keycloak-saml.xml | 65 +++++++++++++++ .../employee-dom/WEB-INF/keystore.jks | Bin 0 -> 2582 bytes .../adapter-test/keycloak-saml/testsaml.json | 55 +++++++++++++ 30 files changed, 430 insertions(+), 84 deletions(-) rename adapters/saml/as7-eap6/subsystem/src/test/resources/org/keycloak/subsystem/saml/as7/{keycloak-saml-1.1.xml => keycloak-saml-1.2.xml} (96%) create mode 100755 adapters/saml/core/src/test/resources/org/keycloak/adapters/saml/config/parsers/keycloak-saml-keepdomassertion.xml delete mode 100755 adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1.xml rename adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/{keycloak-saml-1.1-err.xml => keycloak-saml-1.2-err.xml} (97%) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/adapter/page/EmployeeDomServlet.java create mode 100755 testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keycloak-saml.xml create mode 100755 testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keystore.jks diff --git a/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/Constants.java b/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/Constants.java index 21f2608694..d9fcd22fd8 100755 --- a/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/Constants.java +++ b/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/Constants.java @@ -29,6 +29,7 @@ public class Constants { static final String NAME_ID_POLICY_FORMAT = "nameIDPolicyFormat"; static final String LOGOUT_PAGE = "logoutPage"; static final String FORCE_AUTHENTICATION = "forceAuthentication"; + static final String KEEP_DOM_ASSERTION = "keepDOMAssertion"; static final String IS_PASSIVE = "isPassive"; static final String TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN = "turnOffChangeSessionIdOnLogin"; static final String ROLE_ATTRIBUTES = "RoleIdentifiers"; @@ -83,6 +84,7 @@ public class Constants { static final String NAME_ID_POLICY_FORMAT = "nameIDPolicyFormat"; static final String LOGOUT_PAGE = "logoutPage"; static final String FORCE_AUTHENTICATION = "forceAuthentication"; + static final String KEEP_DOM_ASSERTION = "keepDOMAssertion"; static final String ROLE_IDENTIFIERS = "RoleIdentifiers"; static final String SIGNING = "signing"; static final String ENCRYPTION = "encryption"; diff --git a/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/ServiceProviderDefinition.java b/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/ServiceProviderDefinition.java index aff84ed6f0..cf5f15a8cc 100755 --- a/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/ServiceProviderDefinition.java +++ b/adapters/saml/as7-eap6/subsystem/src/main/java/org/keycloak/subsystem/saml/as7/ServiceProviderDefinition.java @@ -60,6 +60,11 @@ public class ServiceProviderDefinition extends SimpleResourceDefinition { .setXmlName(Constants.XML.FORCE_AUTHENTICATION) .build(); + static final SimpleAttributeDefinition KEEP_DOM_ASSERTION = + new SimpleAttributeDefinitionBuilder(Constants.Model.KEEP_DOM_ASSERTION, ModelType.BOOLEAN, true) + .setXmlName(Constants.XML.KEEP_DOM_ASSERTION) + .build(); + static final SimpleAttributeDefinition IS_PASSIVE = new SimpleAttributeDefinitionBuilder(Constants.Model.IS_PASSIVE, ModelType.BOOLEAN, true) .setXmlName(Constants.XML.IS_PASSIVE) @@ -96,7 +101,7 @@ public class ServiceProviderDefinition extends SimpleResourceDefinition { .build(); static final SimpleAttributeDefinition[] ATTRIBUTES = {SSL_POLICY, NAME_ID_POLICY_FORMAT, LOGOUT_PAGE, FORCE_AUTHENTICATION, - IS_PASSIVE, TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN}; + IS_PASSIVE, TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN, KEEP_DOM_ASSERTION}; static final AttributeDefinition[] ELEMENTS = {PRINCIPAL_NAME_MAPPING_POLICY, PRINCIPAL_NAME_MAPPING_ATTRIBUTE_NAME, ROLE_ATTRIBUTES, ROLE_MAPPINGS_PROVIDER_ID, ROLE_MAPPINGS_PROVIDER_CONFIG}; diff --git a/adapters/saml/as7-eap6/subsystem/src/main/resources/org/keycloak/subsystem/saml/as7/LocalDescriptions.properties b/adapters/saml/as7-eap6/subsystem/src/main/resources/org/keycloak/subsystem/saml/as7/LocalDescriptions.properties index a247ee7858..e901b12c03 100755 --- a/adapters/saml/as7-eap6/subsystem/src/main/resources/org/keycloak/subsystem/saml/as7/LocalDescriptions.properties +++ b/adapters/saml/as7-eap6/subsystem/src/main/resources/org/keycloak/subsystem/saml/as7/LocalDescriptions.properties @@ -32,6 +32,7 @@ keycloak-saml.SP.sslPolicy=SSL Policy to use keycloak-saml.SP.nameIDPolicyFormat=Name ID policy format URN keycloak-saml.SP.logoutPage=URI to a logout page keycloak-saml.SP.forceAuthentication=Redirected unauthenticated request to a login page +keycloak-saml.SP.keepDOMAssertion=Attribute to inject the DOM representation of the assertion into the SamlPrincipal (respecting the original syntax) keycloak-saml.SP.isPassive=If user isn't logged in just return with an error. Used to check if a user is already logged in or not keycloak-saml.SP.turnOffChangeSessionIdOnLogin=The session id is changed by default on a successful login. Change this to true if you want to turn this off keycloak-saml.SP.RoleIdentifiers=Role identifiers @@ -84,4 +85,4 @@ keycloak-saml.IDP.SingleLogoutService.requestBinding=HTTP method to use for requ keycloak-saml.IDP.SingleLogoutService.responseBinding=HTTP method to use for response keycloak-saml.IDP.SingleLogoutService.postBindingUrl=Endpoint URL for posting keycloak-saml.IDP.SingleLogoutService.redirectBindingUrl=Endpoint URL for redirects -keycloak-saml.IDP.Key=Key definition for identity provider \ No newline at end of file +keycloak-saml.IDP.Key=Key definition for identity provider diff --git a/adapters/saml/as7-eap6/subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd b/adapters/saml/as7-eap6/subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd index 5eca1ac311..ff69122b39 100755 --- a/adapters/saml/as7-eap6/subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd +++ b/adapters/saml/as7-eap6/subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd @@ -1,7 +1,7 @@ - + @@ -65,4 +66,4 @@ - \ No newline at end of file + diff --git a/adapters/saml/core-public/src/main/java/org/keycloak/adapters/saml/SamlPrincipal.java b/adapters/saml/core-public/src/main/java/org/keycloak/adapters/saml/SamlPrincipal.java index 136557ffac..6eb8571f78 100755 --- a/adapters/saml/core-public/src/main/java/org/keycloak/adapters/saml/SamlPrincipal.java +++ b/adapters/saml/core-public/src/main/java/org/keycloak/adapters/saml/SamlPrincipal.java @@ -28,6 +28,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Set; +import org.w3c.dom.Document; /** * @author Bill Burke @@ -43,14 +44,20 @@ public class SamlPrincipal implements Serializable, Principal { private String samlSubject; private String nameIDFormat; private AssertionType assertion; + private Document assertionDocument; public SamlPrincipal(AssertionType assertion, String name, String samlSubject, String nameIDFormat, MultivaluedHashMap attributes, MultivaluedHashMap friendlyAttributes) { + this(assertion, null, name, samlSubject, nameIDFormat, attributes, friendlyAttributes); + } + + public SamlPrincipal(AssertionType assertion, Document assertionDocument, String name, String samlSubject, String nameIDFormat, MultivaluedHashMap attributes, MultivaluedHashMap friendlyAttributes) { this.name = name; this.attributes = attributes; this.friendlyAttributes = friendlyAttributes; this.samlSubject = samlSubject; this.nameIDFormat = nameIDFormat; this.assertion = assertion; + this.assertionDocument = assertionDocument; } public SamlPrincipal() { @@ -104,6 +111,16 @@ public class SamlPrincipal implements Serializable, Principal { return res; } + /* + * The assertion element in DOM format, to respect the original syntax. + * It's only available if option keepDOMAssertion is set to true. + * + * @return The document assertion or null + */ + public Document getAssertionDocument() { + return assertionDocument; + } + @Override public String getName() { return name; diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/DefaultSamlDeployment.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/DefaultSamlDeployment.java index 85aa952564..2194d8cda5 100755 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/DefaultSamlDeployment.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/DefaultSamlDeployment.java @@ -315,6 +315,7 @@ public class DefaultSamlDeployment implements SamlDeployment { private SignatureAlgorithm signatureAlgorithm; private String signatureCanonicalizationMethod; private boolean autodetectBearerOnly; + private boolean keepDOMAssertion; @Override public boolean turnOffChangeSessionIdOnLogin() { @@ -478,4 +479,13 @@ public class DefaultSamlDeployment implements SamlDeployment { public void setAutodetectBearerOnly(boolean autodetectBearerOnly) { this.autodetectBearerOnly = autodetectBearerOnly; } + + @Override + public boolean isKeepDOMAssertion() { + return keepDOMAssertion; + } + + public void setKeepDOMAssertion(Boolean keepDOMAssertion) { + this.keepDOMAssertion = keepDOMAssertion != null && keepDOMAssertion; + } } diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/SamlDeployment.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/SamlDeployment.java index 492e92a681..5aeee06d26 100755 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/SamlDeployment.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/SamlDeployment.java @@ -183,4 +183,6 @@ public interface SamlDeployment { String getPrincipalAttributeName(); boolean isAutodetectBearerOnly(); + boolean isKeepDOMAssertion(); + } diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/SP.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/SP.java index 1e3347ea8e..e6644430bc 100755 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/SP.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/SP.java @@ -90,6 +90,7 @@ public class SP implements Serializable { private RoleMappingsProviderConfig roleMappingsProviderConfig; private IDP idp; private boolean autodetectBearerOnly; + private boolean keepDOMAssertion; public String getEntityID() { return entityID; @@ -131,6 +132,14 @@ public class SP implements Serializable { this.turnOffChangeSessionIdOnLogin = turnOffChangeSessionIdOnLogin != null && turnOffChangeSessionIdOnLogin; } + public boolean isKeepDOMAssertion() { + return keepDOMAssertion; + } + + public void setKeepDOMAssertion(Boolean keepDOMAssertion) { + this.keepDOMAssertion = keepDOMAssertion != null && keepDOMAssertion; + } + public List getKeys() { return keys; } diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/DeploymentBuilder.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/DeploymentBuilder.java index 91a6d34e37..33045ad0d4 100755 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/DeploymentBuilder.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/DeploymentBuilder.java @@ -80,6 +80,7 @@ public class DeploymentBuilder { IDP idp = sp.getIdp(); deployment.setSignatureCanonicalizationMethod(idp.getSignatureCanonicalizationMethod()); deployment.setAutodetectBearerOnly(sp.isAutodetectBearerOnly()); + deployment.setKeepDOMAssertion(sp.isKeepDOMAssertion()); deployment.setSignatureAlgorithm(SignatureAlgorithm.RSA_SHA256); if (idp.getSignatureAlgorithm() != null) { deployment.setSignatureAlgorithm(SignatureAlgorithm.valueOf(idp.getSignatureAlgorithm())); diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterV1QNames.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterV1QNames.java index 478a9f2fe6..37df459f61 100644 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterV1QNames.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterV1QNames.java @@ -90,6 +90,7 @@ public enum KeycloakSamlAdapterV1QNames implements HasQName { ATTR_VALIDATE_REQUEST_SIGNATURE(null, "validateRequestSignature"), ATTR_VALIDATE_RESPONSE_SIGNATURE(null, "validateResponseSignature"), ATTR_VALUE(null, "value"), + ATTR_KEEP_DOM_ASSERTION(null, "keepDOMAssertion"), UNKNOWN_ELEMENT("") ; diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/SpParser.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/SpParser.java index 29c6252c8e..e1859a64d3 100644 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/SpParser.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/config/parsers/SpParser.java @@ -53,6 +53,7 @@ public class SpParser extends AbstractKeycloakSamlAdapterV1Parser { sp.setIsPassive(StaxParserUtil.getBooleanAttributeValueRP(element, KeycloakSamlAdapterV1QNames.ATTR_IS_PASSIVE)); sp.setAutodetectBearerOnly(StaxParserUtil.getBooleanAttributeValueRP(element, KeycloakSamlAdapterV1QNames.ATTR_AUTODETECT_BEARER_ONLY)); sp.setTurnOffChangeSessionIdOnLogin(StaxParserUtil.getBooleanAttributeValueRP(element, KeycloakSamlAdapterV1QNames.ATTR_TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN)); + sp.setKeepDOMAssertion(StaxParserUtil.getBooleanAttributeValueRP(element, KeycloakSamlAdapterV1QNames.ATTR_KEEP_DOM_ASSERTION)); return sp; } diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/profile/AbstractSamlAuthenticationHandler.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/profile/AbstractSamlAuthenticationHandler.java index 0195d869cf..c96bed25fd 100644 --- a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/profile/AbstractSamlAuthenticationHandler.java +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/profile/AbstractSamlAuthenticationHandler.java @@ -375,9 +375,11 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic return AuthOutcome.FAILED; } + Element assertionElement = null; if (deployment.getIDP().getSingleSignOnService().validateAssertionSignature()) { try { - if (!AssertionUtil.isSignatureValid(getAssertionFromResponse(responseHolder), deployment.getIDP().getSignatureValidationKeyLocator())) { + assertionElement = getAssertionFromResponse(responseHolder); + if (!AssertionUtil.isSignatureValid(assertionElement, deployment.getIDP().getSignatureValidationKeyLocator())) { log.error("Failed to verify saml assertion signature"); challenge = new AuthChallenge() { @@ -493,7 +495,13 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic URI nameFormat = subjectNameID == null ? null : subjectNameID.getFormat(); String nameFormatString = nameFormat == null ? JBossSAMLURIConstants.NAMEID_FORMAT_UNSPECIFIED.get() : nameFormat.toString(); - final SamlPrincipal principal = new SamlPrincipal(assertion, principalName, principalName, nameFormatString, attributes, friendlyAttributes); + if (deployment.isKeepDOMAssertion() && assertionElement == null) { + // obtain the assertion from the response to add the DOM document to the principal + assertionElement = getAssertionFromResponseNoException(responseHolder); + } + final SamlPrincipal principal = new SamlPrincipal(assertion, + deployment.isKeepDOMAssertion()? getAssertionDocumentFromElement(assertionElement) : null, + principalName, principalName, nameFormatString, attributes, friendlyAttributes); final String sessionIndex = authn == null ? null : authn.getSessionIndex(); final XMLGregorianCalendar sessionNotOnOrAfter = authn == null ? null : authn.getSessionNotOnOrAfter(); SamlSession account = new SamlSession(principal, roles, sessionIndex, sessionNotOnOrAfter); @@ -534,6 +542,30 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic return DocumentUtil.getElement(responseHolder.getSamlDocument(), new QName(JBossSAMLConstants.ASSERTION.get())); } + private Element getAssertionFromResponseNoException(final SAMLDocumentHolder responseHolder) { + try { + return getAssertionFromResponse(responseHolder); + } catch (ConfigurationException|ProcessingException e) { + log.warn("Cannot obtain DOM assertion element", e); + return null; + } + } + + private Document getAssertionDocumentFromElement(final Element assertionElement) { + if (assertionElement == null) { + return null; + } + try { + Document assertionDoc = DocumentUtil.createDocument(); + assertionDoc.adoptNode(assertionElement); + assertionDoc.appendChild(assertionElement); + return assertionDoc; + } catch (ConfigurationException e) { + log.warn("Cannot obtain DOM assertion document", e); + return null; + } + } + private String getAttributeValue(Object attrValue) { String value = null; if (attrValue instanceof String) { diff --git a/adapters/saml/core/src/main/resources/schema/keycloak_saml_adapter_1_12.xsd b/adapters/saml/core/src/main/resources/schema/keycloak_saml_adapter_1_12.xsd index 4b7b9573fd..8f682e9d81 100644 --- a/adapters/saml/core/src/main/resources/schema/keycloak_saml_adapter_1_12.xsd +++ b/adapters/saml/core/src/main/resources/schema/keycloak_saml_adapter_1_12.xsd @@ -97,6 +97,11 @@ SAML clients can request that a user is re-authenticated even if they are already logged in at the IDP. Default value is false. + + + Attribute to inject the DOM representation of the assertion into the SamlPrincipal (respecting the original syntax). Default value is false + + SAML clients can request that a user is never asked to authenticate even if they are not logged in at the IDP. Set this to true if you want this. Do not use together with forceAuthentication as they are opposite. Default value is false. diff --git a/adapters/saml/core/src/test/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterXMLParserTest.java b/adapters/saml/core/src/test/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterXMLParserTest.java index b7613e0fe1..e5115f65b2 100755 --- a/adapters/saml/core/src/test/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterXMLParserTest.java +++ b/adapters/saml/core/src/test/java/org/keycloak/adapters/saml/config/parsers/KeycloakSamlAdapterXMLParserTest.java @@ -86,6 +86,17 @@ public class KeycloakSamlAdapterXMLParserTest { testValidationValid("keycloak-saml-with-role-mappings-provider.xml"); } + @Test + public void testValidationWithKeepDOMAssertion() throws Exception { + testValidationValid("keycloak-saml-keepdomassertion.xml"); + // check keep dom assertion is TRUE + KeycloakSamlAdapter config = parseKeycloakSamlAdapterConfig("keycloak-saml-keepdomassertion.xml", KeycloakSamlAdapter.class); + assertNotNull(config); + assertEquals(1, config.getSps().size()); + SP sp = config.getSps().get(0); + assertTrue(sp.isKeepDOMAssertion()); + } + @Test public void testValidationKeyInvalid() throws Exception { InputStream schemaIs = KeycloakSamlAdapterV1Parser.class.getResourceAsStream(CURRENT_XSD_LOCATION); @@ -115,6 +126,7 @@ public class KeycloakSamlAdapterXMLParserTest { assertTrue(sp.isForceAuthentication()); assertTrue(sp.isIsPassive()); assertFalse(sp.isAutodetectBearerOnly()); + assertFalse(sp.isKeepDOMAssertion()); assertEquals(2, sp.getKeys().size()); Key signing = sp.getKeys().get(0); assertTrue(signing.isSigning()); diff --git a/adapters/saml/core/src/test/resources/org/keycloak/adapters/saml/config/parsers/keycloak-saml-keepdomassertion.xml b/adapters/saml/core/src/test/resources/org/keycloak/adapters/saml/config/parsers/keycloak-saml-keepdomassertion.xml new file mode 100755 index 0000000000..f7e99b0ff3 --- /dev/null +++ b/adapters/saml/core/src/test/resources/org/keycloak/adapters/saml/config/parsers/keycloak-saml-keepdomassertion.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + private pem + + + public pem + + + + + + + + + + + + + + + cert pem + + + + + + diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/Constants.java b/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/Constants.java index 1d671c188e..90f139c5b1 100755 --- a/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/Constants.java +++ b/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/Constants.java @@ -30,6 +30,7 @@ public class Constants { static final String NAME_ID_POLICY_FORMAT = "nameIDPolicyFormat"; static final String LOGOUT_PAGE = "logoutPage"; static final String FORCE_AUTHENTICATION = "forceAuthentication"; + static final String KEEP_DOM_ASSERTION = "keepDOMAssertion"; static final String IS_PASSIVE = "isPassive"; static final String TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN = "turnOffChangeSessionIdOnLogin"; static final String ROLE_ATTRIBUTES = "RoleIdentifiers"; @@ -86,6 +87,7 @@ public class Constants { static final String NAME_ID_POLICY_FORMAT = "nameIDPolicyFormat"; static final String LOGOUT_PAGE = "logoutPage"; static final String FORCE_AUTHENTICATION = "forceAuthentication"; + static final String KEEP_DOM_ASSERTION = "keepDOMAssertion"; static final String ROLE_IDENTIFIERS = "RoleIdentifiers"; static final String SIGNING = "signing"; static final String ENCRYPTION = "encryption"; diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/ServiceProviderDefinition.java b/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/ServiceProviderDefinition.java index caa5aa5b4b..5d859d941e 100755 --- a/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/ServiceProviderDefinition.java +++ b/adapters/saml/wildfly/wildfly-subsystem/src/main/java/org/keycloak/subsystem/adapter/saml/extension/ServiceProviderDefinition.java @@ -62,6 +62,11 @@ public class ServiceProviderDefinition extends SimpleResourceDefinition { .setXmlName(Constants.XML.FORCE_AUTHENTICATION) .build(); + static final SimpleAttributeDefinition KEEP_DOM_ASSERTION = + new SimpleAttributeDefinitionBuilder(Constants.Model.KEEP_DOM_ASSERTION, ModelType.BOOLEAN, true) + .setXmlName(Constants.XML.KEEP_DOM_ASSERTION) + .build(); + static final SimpleAttributeDefinition IS_PASSIVE = new SimpleAttributeDefinitionBuilder(Constants.Model.IS_PASSIVE, ModelType.BOOLEAN, true) .setXmlName(Constants.XML.IS_PASSIVE) @@ -97,7 +102,7 @@ public class ServiceProviderDefinition extends SimpleResourceDefinition { .build(); static final SimpleAttributeDefinition[] ATTRIBUTES = {SSL_POLICY, NAME_ID_POLICY_FORMAT, LOGOUT_PAGE, FORCE_AUTHENTICATION, - IS_PASSIVE, TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN}; + IS_PASSIVE, TURN_OFF_CHANGE_SESSSION_ID_ON_LOGIN, KEEP_DOM_ASSERTION}; static final AttributeDefinition[] ELEMENTS = {PRINCIPAL_NAME_MAPPING_POLICY, PRINCIPAL_NAME_MAPPING_ATTRIBUTE_NAME, ROLE_ATTRIBUTES, ROLE_MAPPINGS_PROVIDER_ID, ROLE_MAPPINGS_PROVIDER_CONFIG}; diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/org/keycloak/subsystem/adapter/saml/extension/LocalDescriptions.properties b/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/org/keycloak/subsystem/adapter/saml/extension/LocalDescriptions.properties index 23e604dd9d..a5484a648f 100755 --- a/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/org/keycloak/subsystem/adapter/saml/extension/LocalDescriptions.properties +++ b/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/org/keycloak/subsystem/adapter/saml/extension/LocalDescriptions.properties @@ -32,6 +32,7 @@ keycloak-saml.SP.sslPolicy=SSL Policy to use keycloak-saml.SP.nameIDPolicyFormat=Name ID policy format URN keycloak-saml.SP.logoutPage=URI to a logout page keycloak-saml.SP.forceAuthentication=Redirected unauthenticated request to a login page +keycloak-saml.SP.keepDOMAssertion=Attribute to inject the DOM representation of the assertion into the SamlPrincipal (respecting the original syntax) keycloak-saml.SP.isPassive=If user isn't logged in just return with an error. Used to check if a user is already logged in or not keycloak-saml.SP.turnOffChangeSessionIdOnLogin=The session id is changed by default on a successful login. Change this to true if you want to turn this off keycloak-saml.SP.RoleIdentifiers=Role identifiers @@ -83,4 +84,4 @@ keycloak-saml.IDP.SingleLogoutService.requestBinding=HTTP method to use for requ keycloak-saml.IDP.SingleLogoutService.responseBinding=HTTP method to use for response keycloak-saml.IDP.SingleLogoutService.postBindingUrl=Endpoint URL for posting keycloak-saml.IDP.SingleLogoutService.redirectBindingUrl=Endpoint URL for redirects -keycloak-saml.IDP.Key=Key definition for identity provider \ No newline at end of file +keycloak-saml.IDP.Key=Key definition for identity provider diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd b/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd index baa10c6ac6..ff69122b39 100755 --- a/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd +++ b/adapters/saml/wildfly/wildfly-subsystem/src/main/resources/schema/wildfly-keycloak-saml_1_2.xsd @@ -84,6 +84,11 @@ Redirected unauthenticated request to a login page + + + Attribute to inject the DOM representation of the assertion into the SamlPrincipal (respecting the original syntax). Default value is false + + If user isn't logged in just return with an error. Used to check if a user is already logged in or not diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1.xml b/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1.xml deleted file mode 100755 index bb3bce855a..0000000000 --- a/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - my_key.pem - my_key.pub - cert.cer - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1-err.xml b/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2-err.xml similarity index 97% rename from adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1-err.xml rename to adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2-err.xml index 5afd0bf721..6733125e55 100644 --- a/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.1-err.xml +++ b/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2-err.xml @@ -15,7 +15,7 @@ ~ limitations under the License. --> - + - \ No newline at end of file + diff --git a/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2.xml b/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2.xml index 71f400a30b..bb0fc98985 100755 --- a/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2.xml +++ b/adapters/saml/wildfly/wildfly-subsystem/src/test/resources/org/keycloak/subsystem/adapter/saml/extension/keycloak-saml-1.2.xml @@ -21,6 +21,7 @@ sslPolicy="EXTERNAL" nameIDPolicyFormat="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" logoutPage="/logout.jsp" + keepDOMAssertion="false" forceAuthentication="false" isPassive="true" turnOffChangeSessionIdOnLogin="true"> @@ -72,4 +73,4 @@ - \ No newline at end of file + diff --git a/services/src/main/java/org/keycloak/protocol/saml/installation/KeycloakSamlSubsystemInstallation.java b/services/src/main/java/org/keycloak/protocol/saml/installation/KeycloakSamlSubsystemInstallation.java index bde0ccd3d9..be44455fd0 100755 --- a/services/src/main/java/org/keycloak/protocol/saml/installation/KeycloakSamlSubsystemInstallation.java +++ b/services/src/main/java/org/keycloak/protocol/saml/installation/KeycloakSamlSubsystemInstallation.java @@ -58,7 +58,7 @@ public class KeycloakSamlSubsystemInstallation implements ClientInstallationProv @Override public String getHelpText() { - return "Keycloak SAML adapter Wildfly/JBoss subsystem xml. Put this element of your standalone.xml file."; + return "Keycloak SAML adapter Wildfly/JBoss subsystem xml. Put this element of your standalone.xml file."; } @Override diff --git a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java index 7ff56ce7cf..2b59000295 100755 --- a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java +++ b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/SendUsernameServlet.java @@ -39,10 +39,19 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.xml.datatype.XMLGregorianCalendar; import java.io.IOException; +import java.io.StringWriter; import java.security.Principal; import java.util.Arrays; import java.util.Collections; import java.util.List; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; /** * @author Bill Burke @@ -94,6 +103,25 @@ public class SendUsernameServlet { } + @GET + @Path("getAssertionFromDocument") + public Response getAssertionFromDocument() throws IOException, TransformerException { + sentPrincipal = httpServletRequest.getUserPrincipal(); + DocumentBuilderFactory domFact = DocumentBuilderFactory.newInstance(); + Document doc = ((SamlPrincipal) sentPrincipal).getAssertionDocument(); + String xml = ""; + if (doc != null) { + DOMSource domSource = new DOMSource(doc); + StringWriter writer = new StringWriter(); + StreamResult result = new StreamResult(writer); + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer transformer = tf.newTransformer(); + transformer.transform(domSource, result); + xml = writer.toString(); + } + return Response.ok(xml).header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_TYPE + ";charset=UTF-8").build(); + } + @GET @Path("{path}") public Response doGetElseWhere(@PathParam("path") String path, @QueryParam("checkRoles") boolean checkRolesFlag) throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/adapter/page/EmployeeDomServlet.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/adapter/page/EmployeeDomServlet.java new file mode 100644 index 0000000000..f690ddf39b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/adapter/page/EmployeeDomServlet.java @@ -0,0 +1,39 @@ +/* + * Copyright 2019 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.testsuite.adapter.page; + +import org.jboss.arquillian.container.test.api.OperateOnDeployment; +import org.jboss.arquillian.test.api.ArquillianResource; + +import java.net.URL; + +/** + * @author rmartinc + */ +public class EmployeeDomServlet extends SAMLServlet { + public static final String DEPLOYMENT_NAME = "employee-dom"; + + @ArquillianResource + @OperateOnDeployment(DEPLOYMENT_NAME) + private URL url; + + @Override + public URL getInjectedUrl() { + return url; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java index b0bfd038d1..a7068b0fb3 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/SAMLServletAdapterTest.java @@ -39,10 +39,13 @@ import java.io.Closeable; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; +import java.io.StringReader; import java.net.URI; import java.net.URL; import java.security.KeyPair; import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -132,6 +135,7 @@ import org.keycloak.saml.common.util.DocumentUtil; import org.keycloak.saml.common.util.XmlKeyInfoKeyNameTransformer; import org.keycloak.saml.processing.core.parsers.saml.SAMLParser; import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder; +import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil; import org.keycloak.services.resources.RealmsResource; import org.keycloak.testsuite.adapter.page.*; import org.keycloak.testsuite.admin.ApiUtil; @@ -189,6 +193,9 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest { @Page protected Employee2Servlet employee2ServletPage; + @Page + protected EmployeeDomServlet employeeDomServletPage; + @Page protected EmployeeSigServlet employeeSigServletPage; @@ -307,6 +314,11 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest { return samlServletDeployment(Employee2Servlet.DEPLOYMENT_NAME, SendUsernameServlet.class); } + @Deployment(name = EmployeeDomServlet.DEPLOYMENT_NAME) + protected static WebArchive employeedom() { + return samlServletDeployment(EmployeeDomServlet.DEPLOYMENT_NAME, SendUsernameServlet.class); + } + @Deployment(name = EmployeeSigServlet.DEPLOYMENT_NAME) protected static WebArchive employeeSig() { return samlServletDeployment(EmployeeSigServlet.DEPLOYMENT_NAME, SendUsernameServlet.class); @@ -1421,6 +1433,10 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest { waitUntilElement(By.xpath("//body")).text().contains("phone: 617"); waitUntilElement(By.xpath("//body")).text().contains("friendlyAttribute phone: null"); + driver.navigate().to(employee2ServletPage.getUriBuilder().clone().path("getAssertionFromDocument").build().toURL()); + waitForPageToLoad(); + Assert.assertEquals("", driver.getPageSource()); + employee2ServletPage.logout(); checkLoggedOut(employee2ServletPage, testRealmSAMLPostLoginPage); @@ -1483,6 +1499,25 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest { validateXMLWithSchema(driver.getPageSource(), "/adapter-test/keycloak-saml/metadata-schema/saml-schema-metadata-2.0.xsd"); } + @Test + public void testDOMAssertion() throws Exception { + assertSuccessfulLogin(employeeDomServletPage, bburkeUser, testRealmSAMLPostLoginPage, "principal=bburke"); + assertSuccessfullyLoggedIn(employeeDomServletPage, "principal=bburke"); + + driver.navigate().to(employeeDomServletPage.getUriBuilder().clone().path("getAssertionFromDocument").build().toURL()); + waitForPageToLoad(); + String xml = driver.getPageSource(); + Assert.assertNotEquals("", xml); + Document doc = DocumentUtil.getDocument(new StringReader(xml)); + String certBase64 = DocumentUtil.getElement(doc, new QName("http://www.w3.org/2000/09/xmldsig#", "X509Certificate")).getTextContent(); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + Certificate cert = cf.generateCertificate(new ByteArrayInputStream(Base64.decode(certBase64))); + PublicKey pubkey = cert.getPublicKey(); + Assert.assertTrue(AssertionUtil.isSignatureValid(doc.getDocumentElement(), pubkey)); + + employeeDomServletPage.logout(); + checkLoggedOut(employeeDomServletPage, testRealmSAMLPostLoginPage); + } @Test public void spMetadataValidation() throws Exception { diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keycloak-saml.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keycloak-saml.xml new file mode 100755 index 0000000000..79543164c3 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keycloak-saml.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keystore.jks b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/employee-dom/WEB-INF/keystore.jks new file mode 100755 index 0000000000000000000000000000000000000000..e02b80009385cdb0aad7fae4e9f0bebabfaf459a GIT binary patch literal 2582 zcmb7Ec{tSj9-iOK7+VY@OBh??ApFKY$l*kB?39`@2azGh7ICsQwfnDa4Fz zXt7K+Cn91jQ<9P`VTcfMopaBr6{E*SDSu|?mkC1 z@{=gyou}0cA^0CVICsC&$~XdcfkB5PKTbS~I*d_jP<(PK=)kq;AC7Bc)S#-@lHaV- zBO=>B8qoT65r&zkJ&U&hHE6vUh#Xp0&d3N6m6O?Js& zh%Qk?x2@Zdmwq&A1TSwlMtq){e-N2zssE*M<_N^qzYojNa_|9NC^`}=xvt{BPQV)c zc(+!n%-xvU<1h7~bL?Dd?bOJK5FFLmT!>^i*?*R-kK9Hi1-NAAtpd3`C4nM=UYu7;kZ-c&~GeoD8l z_EE^VNGo6*HE={tzM=04FV7J-l)mJIx zD|5A9kxAP-Tq)PNpZUmiC_iBitZ~D8OIDWeF)x;D`ZnHm@hZ}Kr?^7pmw!h(VU*ed zT|*#yX2+N3I`hwE95VM_pKue2m6Cm4$?xd;K2nrwPFBgy;5R{8ib*QH0&Yg&cQuZA z4#uHnsl>C!Ye$$!+XUwXrHPekSLKD5EMUI-aJYXBUrvtaDUYvr(%Z?3CL40obGuAY*^E)$kx>{a!5l3gHnxHEO8@=rT-^m@=OFxzD3PQP>>mN6L3IN7#NW~CH5ZDA{TRA#BXS0__we!!x7 zK*@~b=ot3!IAW=DM`nEBxz;BFU8?6y1fD; zhJa#6o5kE!FWb8 zD1hKqO}0$l5C8yvE(oZBISQ&GV5i|Qk^iM_K^buY3>MS}wLvUKR|kt17tqneU@!)M zkN-Db0-*3crTxN&B>+gsK5IT`0sugMKQZNdH;oUS@r$lC#^Oz&*41$DPVD@W2=Ot} z^oVddv9LJQ+4JnYbWH(IXS(WSnBoy2ZYKN4qW3u|S;h?j!f`S=38h-TMPP<~WuUU*U{sGYc-7 zLnJL?;pdvoJtNXEO@b=-?D~R|fOf`cxbo;2>Y?9;O^C@SN9vQGZ005^>oa_qf}&Mb zO ztT^JF??v3d>%NEpByghb+eHTt*>1EQKFK-)< zzsA#5+ghLNTYYrH1Gbc6nO9FGxGO-#HutB35)ZGFN{LIt-Hq39fa=Tu?qI)KYLsX~ zfSlZ5F?)fFIuzk^hI8yFCfN2R*|U4FuQ+D+TbD!Zu^Tlnx?BqsW$eawYMqid?$b3# z*!Ao0w=!VT%XK`+v;29=1idygZ~aZ572nJ?w{D|gTnwyqW7=dT)(v-ILTi#0jmkGE zL6QV_7&jRZSCLwOs3n&4i!OJd+0a^#6GQLo=v53CC4JD?lxE1$5n9a0t|x^2crOGmSyg&pOP-m%XqIdo@#c8Y zcHy2wS8JY{nX81Gt`kMGthCx0PhuuTulDn}D?*`^290kzJtigP6sv5B%T~pqU&GtA zTl9=jz!@=Nm}p|ry5?W5`kvmOmf7d^!`5kN?8156%NmHyE5dD;lS>_+A{VhaTk z#g8;DvyDaxi~R0KIZ9HBjM{sFL%v7ac^1xBvcbckaVmjUd5F;UL%3hsbNp)?>f;P@ UI^l(a*kuAt=o=4