diff --git a/module.xml b/module.xml index f75aebd..86fc14c 100644 --- a/module.xml +++ b/module.xml @@ -7,6 +7,7 @@ + @@ -20,6 +21,6 @@ - + - \ No newline at end of file + diff --git a/pom.xml b/pom.xml index 0d3f7d8..fd5b18b 100644 --- a/pom.xml +++ b/pom.xml @@ -40,7 +40,7 @@ - 3.3.2 + 2.6 1.10.19 @@ -87,8 +87,8 @@ - org.apache.commons - commons-lang3 + commons-lang + commons-lang ${apache.commons.lang} provided diff --git a/src/main/java/com/quest/keycloak/broker/wsfed/RequestedToken.java b/src/main/java/com/quest/keycloak/broker/wsfed/RequestedToken.java index a58c505..9550377 100644 --- a/src/main/java/com/quest/keycloak/broker/wsfed/RequestedToken.java +++ b/src/main/java/com/quest/keycloak/broker/wsfed/RequestedToken.java @@ -18,8 +18,25 @@ import org.keycloak.events.EventBuilder; import org.keycloak.models.KeycloakSession; +import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.saml.processing.core.util.JAXPValidationUtil; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; import javax.ws.rs.core.Response; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathExpression; +import javax.xml.xpath.XPathExpressionException; +import javax.xml.xpath.XPathFactory; +import java.io.IOException; +import java.io.StringReader; import java.security.PublicKey; public interface RequestedToken { @@ -34,4 +51,46 @@ public interface RequestedToken { String getSessionIndex(); Object getToken(); + + String getFirstName(); + + String getLastName(); + + default Document createXmlDocument(String response) throws ProcessingException, ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + + InputSource source = new InputSource(); + source.setCharacterStream(new StringReader(response)); + try { + Document document = builder.parse(source); + JAXPValidationUtil.checkSchemaValidation(document); + return document; + } catch (SAXException | IOException e) { + throw new ProcessingException("Error while extracting SAML from WSFed response."); + } + } + + default Document extractSamlDocument(Document document) throws ProcessingException, XPathExpressionException { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + XPath xpath = XPathFactory.newInstance().newXPath(); + XPathExpression xPathExpression = xpath.compile("//*[local-name() = 'Assertion']"); + + NodeList samlNodes = (NodeList) xPathExpression.evaluate(document, XPathConstants.NODESET); + Document samlDoc = factory.newDocumentBuilder().newDocument(); + for (int i = 0; i < samlNodes.getLength(); i++) { + Node node = samlNodes.item(i); + Node copyNode = samlDoc.importNode(node, true); + samlDoc.appendChild(copyNode); + } + return samlDoc; + } catch (XPathExpressionException | ParserConfigurationException e) { + throw new ProcessingException("Error while extracting SAML Assertion from WSFed XML document."); + } + } + + } diff --git a/src/main/java/com/quest/keycloak/broker/wsfed/SAML11RequestedToken.java b/src/main/java/com/quest/keycloak/broker/wsfed/SAML11RequestedToken.java new file mode 100644 index 0000000..45fccf5 --- /dev/null +++ b/src/main/java/com/quest/keycloak/broker/wsfed/SAML11RequestedToken.java @@ -0,0 +1,298 @@ +/* + * Copyright 2016 Analytical Graphics, 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 com.quest.keycloak.broker.wsfed; + +import com.quest.keycloak.protocol.wsfed.sig.SAML11Signature; +import org.jboss.logging.Logger; +import org.keycloak.dom.saml.v1.assertion.SAML11AssertionType; +import org.keycloak.dom.saml.v1.assertion.SAML11AttributeStatementType; +import org.keycloak.dom.saml.v1.assertion.SAML11AttributeType; +import org.keycloak.dom.saml.v1.assertion.SAML11AudienceRestrictionCondition; +import org.keycloak.dom.saml.v1.assertion.SAML11ConditionAbstractType; +import org.keycloak.dom.saml.v1.assertion.SAML11ConditionsType; +import org.keycloak.dom.saml.v1.assertion.SAML11StatementAbstractType; +import org.keycloak.dom.saml.v1.assertion.SAML11SubjectType; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.exceptions.ConfigurationException; +import org.keycloak.saml.common.exceptions.ParsingException; +import org.keycloak.saml.common.exceptions.ProcessingException; +import org.keycloak.saml.common.util.DocumentUtil; +import org.keycloak.saml.processing.core.parsers.saml.SAMLParser; +import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil; +import org.keycloak.services.ErrorPage; +import org.keycloak.services.messages.Messages; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import javax.ws.rs.core.Response; +import javax.xml.datatype.DatatypeFactory; +import javax.xml.datatype.XMLGregorianCalendar; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.security.PublicKey; +import java.util.List; + +/** + * @author Peter Nalyvayko + * @version $Revision: 1 $ + * @date 10/4/2016 + */ + +public class SAML11RequestedToken implements RequestedToken { + + // private NameIDType subjectNameID; + protected static final Logger logger = Logger.getLogger(SAML2RequestedToken.class); + private SAML11AssertionType samlAssertion; + private String wsfedResponse; + + public SAML11RequestedToken(String wsfedResponse, Object token, RealmModel realm) throws IOException, ParsingException, ProcessingException, ConfigurationException { + this.wsfedResponse = wsfedResponse; + this.samlAssertion = getAssertionType(token, realm); + } + + public static boolean isSignatureValid(Element assertionElement, PublicKey publicKey) { + try { + Document doc = DocumentUtil.createDocument(); + Node n = doc.importNode(assertionElement, true); + doc.appendChild(n); + + return new SAML11Signature().validate(doc, publicKey); + } catch (Exception e) { + logger.error("Cannot validate signature of assertion", e); + } + return false; + } + + @Override + public Response validate(PublicKey key, WSFedIdentityProviderConfig config, EventBuilder event, KeycloakSession session) { + try { + //We have to use the wsfedResponse and pull the document from it. The reason is the WSTrustParser sometimes re-organizes some attributes within the RequestedSecurityToken which breaks validation. + Document doc = createXmlDocument(wsfedResponse); + if(!isSignatureValid(extractSamlDocument(doc).getDocumentElement(), key)) { + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.error(Errors.INVALID_SIGNATURE); + return ErrorPage.error(session, Messages.INVALID_FEDERATED_IDENTITY_ACTION); + } + + XMLGregorianCalendar notBefore = samlAssertion.getConditions().getNotBefore(); + //Add in a tiny bit of slop for small clock differences + notBefore.add(DatatypeFactory.newInstance().newDuration(false, 0, 0, 0, 0, 0, 10)); + + if(AssertionUtil.hasExpired(samlAssertion)) { + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.error(Errors.EXPIRED_CODE); + return ErrorPage.error(session, Messages.INVALID_FEDERATED_IDENTITY_ACTION); + } + + if(!isValidAudienceRestriction(URI.create(config.getWsFedRealm()))) { + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.error(Errors.INVALID_SAML_RESPONSE); + return ErrorPage.error(session, Messages.INVALID_FEDERATED_IDENTITY_ACTION); + } + + } catch (Exception e) { + logger.error("Unable to validate signature", e); + event.event(EventType.IDENTITY_PROVIDER_RESPONSE); + event.error(Errors.INVALID_SAML_RESPONSE); + return ErrorPage.error(session, Messages.INVALID_FEDERATED_IDENTITY_ACTION); + } + + return null; + } + + @Override + public String getFirstName() { + if (!samlAssertion.getStatements().isEmpty()) { + for (SAML11StatementAbstractType st : samlAssertion.getStatements()) { + if (st instanceof SAML11AttributeStatementType) { + SAML11AttributeStatementType attributeStatement = (SAML11AttributeStatementType)st; + for (SAML11AttributeType attribute : attributeStatement.get()) { + if ("givenname".equals(attribute.getAttributeName()) + || JBossSAMLURIConstants.CLAIMS_GIVEN_NAME.get().equalsIgnoreCase(attribute.getAttributeName())) { + if (!attribute.get().isEmpty()) { + return attribute.get().get(0).toString(); + } + } + } + } + } + } + return null; + } + + @Override + public String getLastName() { + if (!samlAssertion.getStatements().isEmpty()) { + for (SAML11StatementAbstractType st : samlAssertion.getStatements()) { + if (st instanceof SAML11AttributeStatementType) { + SAML11AttributeStatementType attributeStatement = (SAML11AttributeStatementType)st; + for (SAML11AttributeType attribute : attributeStatement.get()) { + if ("surname".equals(attribute.getAttributeName()) + || JBossSAMLURIConstants.CLAIMS_SURNAME.get().equalsIgnoreCase(attribute.getAttributeName())) { + if (!attribute.get().isEmpty()) { + return attribute.get().get(0).toString(); + } + } + } + } + } + } + return null; + } + + @Override + public String getUsername() { + if (!samlAssertion.getStatements().isEmpty()) { + for (SAML11StatementAbstractType st : samlAssertion.getStatements()) { + if (st instanceof SAML11AttributeStatementType) { + SAML11AttributeStatementType attributeStatement = (SAML11AttributeStatementType)st; + // The "name" claim is a username + for (SAML11AttributeType attribute : attributeStatement.get()) { + if ("name".equalsIgnoreCase(attribute.getAttributeName()) + || JBossSAMLURIConstants.CLAIMS_NAME.get().equalsIgnoreCase(attribute.getAttributeName())) { + if (!attribute.get().isEmpty()) { + return attribute.get().get(0).toString(); + } + } + } + } + } + } + return null; + } + + @Override + public String getEmail() { + if (!samlAssertion.getStatements().isEmpty()) { + for (SAML11StatementAbstractType st : samlAssertion.getStatements()) { + if (st instanceof SAML11AttributeStatementType) { + SAML11AttributeStatementType attributeStatement = (SAML11AttributeStatementType)st; + for (SAML11AttributeType attribute : attributeStatement.get()) { + if ("emailaddress".equalsIgnoreCase(attribute.getAttributeName()) + || JBossSAMLURIConstants.CLAIMS_EMAIL_ADDRESS_2005.get().equals(attribute.getAttributeName())) { + if (!attribute.get().isEmpty()) { + return attribute.get().get(0).toString(); + } + } + } + } + } + } + return null; + } + + @Override + public String getId() { + if (!samlAssertion.getStatements().isEmpty()) { + for (SAML11StatementAbstractType st : samlAssertion.getStatements()) { + if (st instanceof SAML11AttributeStatementType) { + SAML11AttributeStatementType attributeStatement = (SAML11AttributeStatementType)st; + // First check if the nameIdentifier of the Subject is available + // Can the subject be false? What does the spec say? + SAML11SubjectType subject = attributeStatement.getSubject(); + if (subject != null && subject.getChoice() != null) { + SAML11SubjectType.SAML11SubjectTypeChoice choice = subject.getChoice(); + if (choice.getNameID() != null) { + String nameId = choice.getNameID().getValue(); + if (nameId != null && nameId.length() > 0) { + return nameId; + } + } + } + // The "nameidentifier" is a unique user id. + for (SAML11AttributeType attribute : attributeStatement.get()) { + if ("nameidenfier".equalsIgnoreCase(attribute.getAttributeName()) + || JBossSAMLURIConstants.CLAIMS_NAME_IDENTIFIER.get().equalsIgnoreCase(attribute.getAttributeName())) { + if (!attribute.get().isEmpty()) { + return attribute.get().get(0).toString(); + } + } + } + } + } + } + return getUsername(); + } + + @Override + public String getSessionIndex() { + //TODO: getSessionIndex still needs to be implemented + return null; + } + + @Override + public Object getToken() { return samlAssertion; } + + public boolean isValidAudienceRestriction(URI...uris) { + List audienceRestriction = getAudienceRestrictions(); + + if(audienceRestriction == null) { + return true; + } + + for (URI uri : uris) { + if (audienceRestriction.contains(uri)) { + return true; + } + } + + return false; + } + + public List getAudienceRestrictions() { + SAML11ConditionsType conditions = samlAssertion.getConditions(); + for(SAML11ConditionAbstractType condition : conditions.get()) { + if(condition instanceof SAML11AudienceRestrictionCondition) { + return ((SAML11AudienceRestrictionCondition) condition).get(); + } + } + + return null; + } + + public SAML11AssertionType getAssertionType(Object token, RealmModel realm) throws IOException, ParsingException, ProcessingException, ConfigurationException { + SAML11AssertionType assertionType = null; + ByteArrayInputStream bis = null; + try { + String assertionXml = DocumentUtil.asString(((Element) token).getOwnerDocument()); + + bis = new ByteArrayInputStream(assertionXml.getBytes()); + SAMLParser parser = new SAMLParser(); + Object assertion = parser.parse(bis); + + assertionType = (SAML11AssertionType) assertion; + return assertionType; + } finally { + if (bis != null) { + bis.close(); + } + } + } + + public SAML11AssertionType getAssertionType() { + return samlAssertion; + } +} diff --git a/src/main/java/com/quest/keycloak/broker/wsfed/SAML2RequestedToken.java b/src/main/java/com/quest/keycloak/broker/wsfed/SAML2RequestedToken.java index 263bf82..47ca249 100644 --- a/src/main/java/com/quest/keycloak/broker/wsfed/SAML2RequestedToken.java +++ b/src/main/java/com/quest/keycloak/broker/wsfed/SAML2RequestedToken.java @@ -17,7 +17,14 @@ package com.quest.keycloak.broker.wsfed; import org.jboss.logging.Logger; -import org.keycloak.dom.saml.v2.assertion.*; +import org.keycloak.dom.saml.v2.assertion.AssertionType; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.dom.saml.v2.assertion.AttributeType; +import org.keycloak.dom.saml.v2.assertion.AudienceRestrictionType; +import org.keycloak.dom.saml.v2.assertion.ConditionAbstractType; +import org.keycloak.dom.saml.v2.assertion.EncryptedAssertionType; +import org.keycloak.dom.saml.v2.assertion.NameIDType; +import org.keycloak.dom.saml.v2.assertion.SubjectType; import org.keycloak.events.Errors; import org.keycloak.events.EventBuilder; import org.keycloak.events.EventType; @@ -41,21 +48,13 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; import javax.ws.rs.core.Response; import javax.xml.datatype.DatatypeFactory; import javax.xml.datatype.XMLGregorianCalendar; import javax.xml.namespace.QName; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.xpath.*; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.StringReader; import java.net.URI; import java.security.PrivateKey; import java.security.PublicKey; @@ -143,43 +142,6 @@ public List getAudienceRestrictions() { return null; } - private static Document createXmlDocument(String response) throws ProcessingException, ParserConfigurationException { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - DocumentBuilder builder = null; - - builder = factory.newDocumentBuilder(); - InputSource source = new InputSource(); - source.setCharacterStream(new StringReader(response)); - try { - Document document = builder.parse(source); - JAXPValidationUtil.checkSchemaValidation(document); - return document; - } catch (SAXException | IOException e) { - throw new ProcessingException("Error while extracting SAML from WSFed response."); - } - } - - private Document extractSamlDocument(Document document) throws ProcessingException { - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - XPath xpath = XPathFactory.newInstance().newXPath(); - XPathExpression xPathExpression = xpath.compile("//*[local-name() = 'Assertion']"); - - NodeList samlNodes = (NodeList) xPathExpression.evaluate(document, XPathConstants.NODESET); - Document samlDoc = factory.newDocumentBuilder().newDocument(); - for (int i = 0; i < samlNodes.getLength(); i++) { - Node node = samlNodes.item(i); - Node copyNode = samlDoc.importNode(node, true); - samlDoc.appendChild(copyNode); - } - return samlDoc; - } catch (XPathExpressionException | ParserConfigurationException e) { - throw new ProcessingException("Error while extracting SAML Assertion from WSFed XML document."); - } - } - protected NameIDType getSubjectNameID() { if (subjectNameID == null) { SubjectType subject = saml2Assertion.getSubject(); @@ -197,6 +159,18 @@ public String getUsername() { return getId(); } + @Override + public String getLastName() { + // TODO: implement getLastName + return null; + } + + @Override + public String getFirstName() { + // TODO: implement getFirstName + return null; + } + @Override public String getEmail() { if (getSubjectNameID()!=null && getSubjectNameID().getFormat()!=null) { @@ -286,7 +260,6 @@ public AssertionType getAssertionType() { return saml2Assertion; } - public Object getToken() { - return saml2Assertion; - } + @Override + public Object getToken() { return saml2Assertion; } } diff --git a/src/main/java/com/quest/keycloak/broker/wsfed/WSFedDataMarshaller.java b/src/main/java/com/quest/keycloak/broker/wsfed/WSFedDataMarshaller.java index ce4ca74..dbf006f 100644 --- a/src/main/java/com/quest/keycloak/broker/wsfed/WSFedDataMarshaller.java +++ b/src/main/java/com/quest/keycloak/broker/wsfed/WSFedDataMarshaller.java @@ -17,11 +17,13 @@ package com.quest.keycloak.broker.wsfed; import org.keycloak.broker.provider.DefaultDataMarshaller; +import org.keycloak.dom.saml.v1.assertion.SAML11AssertionType; import org.keycloak.dom.saml.v2.assertion.AssertionType; import org.keycloak.saml.common.exceptions.ParsingException; import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.common.util.StaxUtil; import org.keycloak.saml.processing.core.parsers.saml.SAMLParser; +import org.keycloak.saml.processing.core.saml.v1.writers.SAML11AssertionWriter; import org.keycloak.saml.processing.core.saml.v2.writers.SAMLAssertionWriter; import java.io.ByteArrayInputStream; @@ -42,7 +44,18 @@ public String serialize(Object obj) { } catch (ProcessingException pe) { throw new RuntimeException(pe); } + } + else if (obj instanceof SAML11AssertionType) { + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + SAML11AssertionType assertion = (SAML11AssertionType) obj; + SAML11AssertionWriter samlWriter = new SAML11AssertionWriter(StaxUtil.getXMLStreamWriter(bos)); + samlWriter.write(assertion); + return new String(bos.toByteArray()); + } catch (ProcessingException pe) { + throw new RuntimeException(pe); + } } //else if (obj instanceof JWSInput else { @@ -63,6 +76,17 @@ public T deserialize(String serialized, Class clazz) { throw new RuntimeException(pe); } } + else if (clazz.equals(SAML11AssertionType.class)) { + try { + byte[] bytes = serialized.getBytes(); + InputStream is = new ByteArrayInputStream(bytes); + Object respType = new SAMLParser().parse(is); + + return clazz.cast(respType); + } catch (ParsingException pe) { + throw new RuntimeException(pe); + } + } //else if(clazz.equals(JWSInput.class)) else { return super.deserialize(serialized, clazz); diff --git a/src/main/java/com/quest/keycloak/broker/wsfed/WSFedEndpoint.java b/src/main/java/com/quest/keycloak/broker/wsfed/WSFedEndpoint.java index 57832ff..21de88c 100644 --- a/src/main/java/com/quest/keycloak/broker/wsfed/WSFedEndpoint.java +++ b/src/main/java/com/quest/keycloak/broker/wsfed/WSFedEndpoint.java @@ -279,6 +279,16 @@ protected Response handleLoginResponse(String wsfedResponse, RequestedToken toke identity.setEmail(token.getEmail()); } + String givenName; + if ((givenName = token.getFirstName()) != null) { + identity.setFirstName(givenName); + } + + String surName; + if ((surName = token.getLastName()) != null) { + identity.setLastName(surName); + } + if (config.isStoreToken()) { identity.setToken(wsfedResponse); } @@ -316,7 +326,7 @@ protected Response handleWsFedResponse(String wsfedResponse, String context) { token = new SAML2RequestedToken(session, wsfedResponse, rt, realm); } else if (rstr.getTokenType().compareTo(URI.create("urn:oasis:names:tc:SAML:1.0:assertion")) == 0) { - throw new NotImplementedException("We don't currently support a token type of urn:oasis:names:tc:SAML:1.0:assertion"); + token = new SAML11RequestedToken(wsfedResponse, rt, realm); } else if (rstr.getTokenType().compareTo(URI.create("urn:ietf:params:oauth:token-type:jwt")) == 0) { throw new NotImplementedException("We don't currently support a token type of urn:ietf:params:oauth:token-type:jwt"); diff --git a/src/main/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocol.java b/src/main/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocol.java index 4442ef4..11bbe84 100644 --- a/src/main/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocol.java +++ b/src/main/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocol.java @@ -16,19 +16,10 @@ package com.quest.keycloak.protocol.wsfed; -import java.io.InputStream; -import java.security.KeyPair; -import java.security.PrivateKey; -import java.security.PublicKey; -import java.security.cert.X509Certificate; -import javax.ws.rs.HttpMethod; -import javax.ws.rs.core.HttpHeaders; -import javax.ws.rs.core.Response; -import javax.ws.rs.core.UriInfo; -import javax.xml.datatype.DatatypeConfigurationException; - import com.quest.keycloak.common.wsfed.WSFedConstants; import com.quest.keycloak.common.wsfed.builders.WSFedResponseBuilder; +import com.quest.keycloak.protocol.wsfed.builders.RequestSecurityTokenResponseBuilder; +import com.quest.keycloak.protocol.wsfed.builders.WSFedOIDCAccessTokenBuilder; import com.quest.keycloak.protocol.wsfed.builders.WSFedSAML2AssertionTypeBuilder; import com.quest.keycloak.protocol.wsfed.builders.WsFedSAML11AssertionTypeBuilder; import org.apache.http.HttpEntity; @@ -55,8 +46,13 @@ import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.messages.Messages; -import com.quest.keycloak.protocol.wsfed.builders.RequestSecurityTokenResponseBuilder; -import com.quest.keycloak.protocol.wsfed.builders.WSFedOIDCAccessTokenBuilder; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import javax.xml.datatype.DatatypeConfigurationException; +import java.io.InputStream; +import java.security.KeyPair; /** * Implementation of keycloak's LoginProtocol. This is necessary diff --git a/src/main/java/com/quest/keycloak/protocol/wsfed/builders/RequestSecurityTokenResponseBuilder.java b/src/main/java/com/quest/keycloak/protocol/wsfed/builders/RequestSecurityTokenResponseBuilder.java index 9b3e409..7048438 100644 --- a/src/main/java/com/quest/keycloak/protocol/wsfed/builders/RequestSecurityTokenResponseBuilder.java +++ b/src/main/java/com/quest/keycloak/protocol/wsfed/builders/RequestSecurityTokenResponseBuilder.java @@ -21,7 +21,7 @@ import com.quest.keycloak.protocol.wsfed.sig.SAML11Signature; import com.quest.keycloak.protocol.wsfed.sig.SAML2SignatureProxy; import com.quest.keycloak.protocol.wsfed.sig.SAMLAbstractSignature; -import org.apache.commons.lang3.StringEscapeUtils; +import org.apache.commons.lang.StringEscapeUtils; import org.keycloak.dom.saml.v1.assertion.SAML11AssertionType; import org.keycloak.dom.saml.v2.assertion.AssertionType; import org.keycloak.saml.SignatureAlgorithm; @@ -183,7 +183,7 @@ public Response buildResponse() throws ProcessingException, org.picketlink.commo public RequestSecurityTokenResponse build() throws ConfigurationException, ProcessingException { RequestSecurityTokenResponse response = new RequestSecurityTokenResponse(); - response.setContext(StringEscapeUtils.escapeXml11(context)); + response.setContext(StringEscapeUtils.escapeXml(context)); XMLGregorianCalendar issueInstance = XMLTimeUtil.getIssueInstant(); response.setLifetime(new Lifetime(issueInstance.toGregorianCalendar(), XMLTimeUtil.add(issueInstance, tokenExpiration * 1000).toGregorianCalendar())); diff --git a/src/main/java/com/quest/keycloak/saml/processing/core/saml/v2/util/AssertionUtil.java b/src/main/java/com/quest/keycloak/saml/processing/core/saml/v2/util/AssertionUtil.java index 6b25d6c..a7b1f2a 100644 --- a/src/main/java/com/quest/keycloak/saml/processing/core/saml/v2/util/AssertionUtil.java +++ b/src/main/java/com/quest/keycloak/saml/processing/core/saml/v2/util/AssertionUtil.java @@ -1,19 +1,14 @@ package com.quest.keycloak.saml.processing.core.saml.v2.util; import org.keycloak.dom.saml.v1.assertion.SAML11AssertionType; -import org.keycloak.dom.saml.v1.assertion.SAML11AttributeStatementType; -import org.keycloak.dom.saml.v1.assertion.SAML11AttributeType; -import org.keycloak.dom.saml.v1.assertion.SAML11ConditionsType; -import org.keycloak.dom.saml.v1.assertion.SAML11StatementAbstractType; -import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.common.PicketLinkLogger; import org.keycloak.saml.common.PicketLinkLoggerFactory; +import org.keycloak.saml.common.exceptions.ProcessingException; import org.keycloak.saml.common.util.DocumentUtil; import org.keycloak.saml.common.util.StaxUtil; import org.keycloak.saml.processing.core.saml.v1.writers.SAML11AssertionWriter; import org.w3c.dom.Document; -import javax.xml.datatype.XMLGregorianCalendar; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; diff --git a/src/test/java/com/quest/keycloak/common/wsfed/MockHelper.java b/src/test/java/com/quest/keycloak/common/wsfed/MockHelper.java index 5fd2f68..a2295de 100644 --- a/src/test/java/com/quest/keycloak/common/wsfed/MockHelper.java +++ b/src/test/java/com/quest/keycloak/common/wsfed/MockHelper.java @@ -75,6 +75,7 @@ public class MockHelper { private String realmName = null; private int accessCodeLifespan = 0; private int accessTokenLifespan = 0; + private int accessTokenLifespanForImplicitFlow = 0; private Map protocolMappers = new HashMap<>(); @@ -352,6 +353,15 @@ public MockHelper setAccessTokenLifespan(int accessTokenLifespan) { return this; } + public int getAccessTokenLifespanForImplicitFlow() { + return accessTokenLifespanForImplicitFlow; + } + + public MockHelper setAccessTokenLifespanForExplicitFlow(int accessTokenLifespanForExplicitFlow) { + this.accessTokenLifespanForImplicitFlow = accessTokenLifespanForExplicitFlow; + return this; + } + public UriInfo getUriInfo() { return uriInfo; } diff --git a/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocolTest.java b/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocolTest.java index c93c2b1..7f0ef57 100644 --- a/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocolTest.java +++ b/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedLoginProtocolTest.java @@ -31,6 +31,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.LoginProtocol; +import org.keycloak.saml.processing.core.saml.v1.SAML11Constants; import org.keycloak.services.messages.Messages; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -112,6 +113,22 @@ public void testDontUseJwt() throws Exception { assertFalse(loginProtocol.useJwt(client)); } + @Test + public void testSaml20AssertionTokenFormat() throws Exception { + ClientModel client = mockHelper.getClient(); + doReturn("SAML 2.0").when(client).getAttribute(WSFedLoginProtocol.WSFED_SAML_ASSERTION_TOKEN_FORMAT); + + assertEquals(WsFedSAMLAssertionTokenFormat.SAML20_ASSERTION_TOKEN_FORMAT, loginProtocol.getSamlAssertionTokenFormat(client)); + } + + @Test + public void testSaml11AssertionTokenFormat() throws Exception { + ClientModel client = mockHelper.getClient(); + doReturn("SAML 1.1").when(client).getAttribute(WSFedLoginProtocol.WSFED_SAML_ASSERTION_TOKEN_FORMAT); + + assertEquals(WsFedSAMLAssertionTokenFormat.SAML11_ASSERTION_TOKEN_FORMAT, loginProtocol.getSamlAssertionTokenFormat(client)); + } + @Test public void testIncludeX5t() throws Exception { ClientModel client = mockHelper.getClient(); @@ -152,6 +169,31 @@ public void testAuthenticatedSaml() throws Exception { assertTokenType(wsfedResponse, "http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV2.0"); } + @Test + public void testAuthenticatedSaml11() throws Exception { + ClientModel client = mockHelper.getClient(); + doReturn("false").when(client).getAttribute(WSFedLoginProtocol.WSFED_JWT); + doReturn("SAML 1.1").when(client).getAttribute(WSFedLoginProtocol.WSFED_SAML_ASSERTION_TOKEN_FORMAT); + + Response response = loginProtocol.authenticated(mockHelper.getUserSessionModel(), mockHelper.getAccessCode()); + + //We already validate token generation through other test classes so this is mainly to ensure the response gets built correctly + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals(MediaType.TEXT_HTML_TYPE, response.getMetadata().getFirst("Content-Type")); + assertEquals("no-cache", response.getMetadata().getFirst("Pragma")); + assertEquals("no-cache, no-store", response.getMetadata().getFirst("Cache-Control")); + + Document doc = responseToDocument(response); + + assertFormAction(doc, "POST", mockHelper.getClientSessionModel().getRedirectUri()); + assertInputNode(doc, WSFedConstants.WSFED_ACTION, WSFedConstants.WSFED_SIGNIN_ACTION); + assertInputNode(doc, WSFedConstants.WSFED_REALM, client.getClientId()); + + String wsfedResponse = getInputNodeValue(doc, WSFedConstants.WSFED_RESULT); + assertNotNull(wsfedResponse); + assertTokenType(wsfedResponse, SAML11Constants.ASSERTION_11_NSURI); + } + @Test public void testAuthenticatedJwt() throws Exception { ClientModel client = mockHelper.getClient(); diff --git a/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedServiceTest.java b/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedServiceTest.java index bfff6e6..e746c1a 100644 --- a/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedServiceTest.java +++ b/src/test/java/com/quest/keycloak/protocol/wsfed/WSFedServiceTest.java @@ -24,7 +24,9 @@ import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpResponse; import org.jboss.resteasy.spi.ResteasyProviderFactory; +import org.junit.After; import org.junit.Before; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -41,6 +43,7 @@ import org.keycloak.services.resources.RealmsResource; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.mockito.internal.matchers.EndsWith; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -66,6 +69,7 @@ public class WSFedServiceTest { @Mock private HttpRequest request; @Mock private HttpResponse response; @Mock private ClientConnection clientConnection; + @Mock private IdentityProviderModel identityProvider; private MockHelper mockHelper; private WrappedWSFedService service; @@ -82,6 +86,11 @@ public void setUp() throws Exception { injectMocks(service); } + @After + public void tearDown() throws Exception { + ResteasyProviderFactory.clearContextData(); + } + protected class WrappedWSFedService extends WSFedService { public WrappedWSFedService(RealmModel realm, EventBuilder event) { super(realm, event); @@ -576,4 +585,41 @@ public void testHandleLoginRequest() throws Exception { assertErrorPage(mockHelper.getLoginFormsProvider(), Messages.INVALID_CODE); } + @Test + @Ignore + public void testHandleLoginRequestRedirectToIdentityProvider() throws Exception { + WSFedProtocolParameters params = new WSFedProtocolParameters(); + params.setWsfed_reply("https://redirectUri"); + params.setWsfed_context("context"); + // The home realm parameter 'whr' is used to log in using an identity provider + params.setWsfed_home_realm("dummyIdentityProvider"); + + doReturn(identityProvider).when(mockHelper.getRealm()).getIdentityProviderByAlias("dummyIdentityProvider"); + doReturn(new HashSet<>(Arrays.asList(params.getWsfed_reply()))).when(mockHelper.getClient()).getRedirectUris(); + AuthenticationFlowModel flow = mock(AuthenticationFlowModel.class); + doReturn(UUID.randomUUID().toString()).when(flow).getId(); + doReturn(flow).when(mockHelper.getRealm()).getBrowserFlow(); + + doReturn(SslRequired.EXTERNAL).when(mockHelper.getRealm()).getSslRequired(); + doReturn(new MultivaluedMapImpl()).when(response).getOutputHeaders(); + + ResteasyProviderFactory.pushContext(HttpResponse.class, response); + + ClientSessionModel clientSession = mock(ClientSessionModel.class); + when(clientSession.getId()).thenReturn(UUID.randomUUID().toString()); + //when(clientSession.getNote(ClientSessionModel.ACTION_KEY)).thenReturn(KeycloakModelUtils.generateCodeSecret()); //This is normally set in method but because we are mocked we need to return it + when(clientSession.getClient()).thenReturn(mockHelper.getClient()); + + UserSessionProvider provider = mockHelper.getSession().sessions(); + doReturn(clientSession).when(provider).createClientSession(mockHelper.getRealm(), mockHelper.getClient()); + + Response response = service.handleLoginRequest(params, mockHelper.getClient(), true); + assertNotNull(response); + + // The status code is going to change to 303 or 302! + assertEquals(response.getStatus(), Response.Status.FOUND.getStatusCode()); + assertNotNull(response.getLocation()); + + assertThat(response.getLocation().getPath(), new EndsWith("broker/dummyIdentityProvider/login")); + } } \ No newline at end of file diff --git a/src/test/java/com/quest/keycloak/protocol/wsfed/builders/WsFedSAML11AssertionTypeBuilderTest.java b/src/test/java/com/quest/keycloak/protocol/wsfed/builders/WsFedSAML11AssertionTypeBuilderTest.java new file mode 100644 index 0000000..eae568f --- /dev/null +++ b/src/test/java/com/quest/keycloak/protocol/wsfed/builders/WsFedSAML11AssertionTypeBuilderTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2016 Analytical Graphics, 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 com.quest.keycloak.protocol.wsfed.builders; + +import com.quest.keycloak.common.wsfed.MockHelper; +import com.quest.keycloak.common.wsfed.TestHelpers; +import com.quest.keycloak.protocol.wsfed.builders.WsFedSAML11AssertionTypeBuilder; +import com.quest.keycloak.protocol.wsfed.mappers.WSFedSAMLAttributeStatementMapper; +import com.quest.keycloak.protocol.wsfed.mappers.WSFedSAMLRoleListMapper; +import org.junit.Before; +import org.junit.Test; +import org.keycloak.dom.saml.v1.assertion.SAML11AssertionType; +import org.keycloak.dom.saml.v1.assertion.SAML11AttributeStatementType; +import org.keycloak.dom.saml.v1.assertion.SAML11AudienceRestrictionCondition; +import org.keycloak.dom.saml.v1.assertion.SAML11AuthenticationStatementType; +import org.keycloak.dom.saml.v2.assertion.AttributeStatementType; +import org.keycloak.models.ClientSessionModel; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.saml.common.constants.GeneralConstants; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.processing.core.saml.v2.util.XMLTimeUtil; +import org.mockito.MockitoAnnotations; + +import java.net.URI; +import java.util.UUID; + +import static com.quest.keycloak.protocol.wsfed.builders.SAML11AssertionTypeBuilder.CLOCK_SKEW; +import static com.quest.keycloak.protocol.wsfed.builders.WsFedSAMLAssertionTypeAbstractBuilder.WSFED_NAME_ID_FORMAT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.any; + +/** + * @author Peter Nalyvayko + * @version $Revision: 1 $ + * @date 10/8/2016 + */ + +public class WsFedSAML11AssertionTypeBuilderTest { + + private MockHelper mockHelper; + + @Before + public void Before() { + MockitoAnnotations.initMocks(this); + mockHelper = TestHelpers.getMockHelper(); + } + + @Test + public void testSamlTokenGeneration() throws Exception { + +// mockHelper.getClientAttributes().put(WSFedSAML11AssertionTypeBuilder, "false"); + mockHelper.getClientSessionNotes().put(GeneralConstants.NAMEID_FORMAT, JBossSAMLURIConstants.NAMEID_FORMAT_UNSPECIFIED.get()); + + //Role Mapper + ProtocolMapperModel roleMappingModel = mock(ProtocolMapperModel.class); + when(roleMappingModel.getProtocolMapper()).thenReturn(UUID.randomUUID().toString()); + WSFedSAMLRoleListMapper roleListMapper = mock(WSFedSAMLRoleListMapper.class); + mockHelper.getProtocolMappers().put(roleMappingModel, roleListMapper); + + //Attribute Mapper + ProtocolMapperModel attributeMappingModel = mock(ProtocolMapperModel.class); + when(attributeMappingModel.getProtocolMapper()).thenReturn(UUID.randomUUID().toString()); + WSFedSAMLAttributeStatementMapper attributeMapper = mock(WSFedSAMLAttributeStatementMapper.class); + mockHelper.getProtocolMappers().put(attributeMappingModel, attributeMapper); + + + mockHelper.initializeMockValues(); + + //SAML Token generation + WsFedSAML11AssertionTypeBuilder samlBuilder = new WsFedSAML11AssertionTypeBuilder(); + samlBuilder.setRealm(mockHelper.getRealm()) + .setUriInfo(mockHelper.getUriInfo()) + .setAccessCode(mockHelper.getAccessCode()) + .setClientSession(mockHelper.getClientSessionModel()) + .setUserSession(mockHelper.getUserSessionModel()) + .setSession(mockHelper.getSession()); + + SAML11AssertionType token = samlBuilder.build(); + + assertNotNull(token); + + assertEquals(String.format("%s/realms/%s", mockHelper.getBaseUri(), mockHelper.getRealmName()), token.getIssuer()); + // TODO fix me! Check the specs if the name id format is a part of the SAML 1.1 token +// assertEquals(URI.create(mockHelper.getClientSessionNotes().get(WSFED_NAME_ID_FORMAT)), JBossSAMLURIConstants.NAMEID_FORMAT_UNSPECIFIED); +// TODO: fix me! + //assertEquals(mockHelper.getEmail(), token.); + + assertNotNull(token.getIssueInstant()); + assertNotNull(token.getConditions().getNotBefore()); + assertNotNull(token.getConditions().getNotOnOrAfter()); + + assertNotNull(token.getStatements()); + assertNotNull(token.getConditions().getNotOnOrAfter()); + + // Verify that the token time is within the time interval specified by the conditions statement + // and that the time interval is adjusted by a small amount to account for clock skew + assertEquals(token.getConditions().getNotBefore(), XMLTimeUtil.subtract(token.getIssueInstant(), CLOCK_SKEW)); + assertEquals(XMLTimeUtil.add(token.getConditions().getNotBefore(), mockHelper.getAccessTokenLifespanForImplicitFlow() * 1000 + CLOCK_SKEW + CLOCK_SKEW), token.getConditions().getNotOnOrAfter()); + assertEquals(XMLTimeUtil.add(token.getIssueInstant(), mockHelper.getAccessTokenLifespanForImplicitFlow() * 1000 + CLOCK_SKEW), token.getConditions().getNotOnOrAfter()); + + assertEquals(mockHelper.getClientId(), ((SAML11AudienceRestrictionCondition) token.getConditions().get().get(0)).get().get(0).toString()); + + assertTrue(token.getStatements().size() > 1); + assertNotNull(token.getStatements().get(1)); + assertTrue(token.getStatements().get(1) instanceof SAML11AuthenticationStatementType); + + SAML11AuthenticationStatementType authType = (SAML11AuthenticationStatementType)token.getStatements().get(1); + assertEquals(authType.getAuthenticationInstant(), token.getIssueInstant()); + assertEquals(authType.getAuthenticationMethod().toString(), JBossSAMLURIConstants.AC_PASSWORD_PROTECTED_TRANSPORT.get()); + assertEquals(authType.getSubject().getSubjectConfirmation().getConfirmationMethod().get(0), URI.create("urn:oasis:names:tc:SAML:1.0:cm:bearer")); + + ClientSessionModel clientSession = mockHelper.getClientSessionModel(); + verify(clientSession, times(1)).setNote(WsFedSAML11AssertionTypeBuilder.WSFED_NAME_ID, mockHelper.getUserName()); + verify(clientSession, times(1)).setNote(WSFED_NAME_ID_FORMAT, mockHelper.getClientSessionNotes().get(GeneralConstants.NAMEID_FORMAT)); + + verify(roleListMapper, times(1)).mapRoles(any(AttributeStatementType.class), eq(roleMappingModel), eq(mockHelper.getSession()), eq(mockHelper.getUserSessionModel()), eq(mockHelper.getClientSessionModel())); + verify(attributeMapper, times(1)).transformAttributeStatement(any(AttributeStatementType.class), eq(attributeMappingModel), eq(mockHelper.getSession()), eq(mockHelper.getUserSessionModel()), eq(mockHelper.getClientSessionModel())); + + } +}