diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/descriptor/parsers/SamlDescriptorIDPKeysExtractor.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/descriptor/parsers/SamlDescriptorIDPKeysExtractor.java new file mode 100644 index 0000000000..0858675c50 --- /dev/null +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/descriptor/parsers/SamlDescriptorIDPKeysExtractor.java @@ -0,0 +1,101 @@ +/* + * 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.adapters.saml.descriptor.parsers; + +import java.io.IOException; +import java.io.InputStream; +import javax.xml.crypto.MarshalException; +import javax.xml.crypto.dom.DOMStructure; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; +import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +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 org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.saml.common.constants.JBossSAMLConstants; +import org.keycloak.saml.common.constants.JBossSAMLURIConstants; +import org.keycloak.saml.common.exceptions.ParsingException; +import org.keycloak.saml.processing.core.util.NamespaceContext; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * Goes through the given XML file and extracts names, certificates and keys from the KeyInfo elements. + * @author hmlnarik + */ +public class SamlDescriptorIDPKeysExtractor { + + private static final NamespaceContext NS_CONTEXT = new NamespaceContext(); + static { + NS_CONTEXT.addNsUriPair("m", JBossSAMLURIConstants.METADATA_NSURI.get()); + NS_CONTEXT.addNsUriPair("dsig", JBossSAMLURIConstants.XMLDSIG_NSURI.get()); + } + + private final KeyInfoFactory kif = KeyInfoFactory.getInstance(); + + private final XPathFactory xPathfactory = XPathFactory.newInstance(); + private final XPath xpath = xPathfactory.newXPath(); + { + xpath.setNamespaceContext(NS_CONTEXT); + } + + public MultivaluedHashMap parse(InputStream stream) throws ParsingException { + MultivaluedHashMap res = new MultivaluedHashMap<>(); + + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(stream); + + XPathExpression expr = xpath.compile("/m:EntitiesDescriptor/m:EntityDescriptor/m:IDPSSODescriptor/m:KeyDescriptor"); + NodeList keyDescriptors = (NodeList) expr.evaluate(doc, XPathConstants.NODESET); + for (int i = 0; i < keyDescriptors.getLength(); i ++) { + Node keyDescriptor = keyDescriptors.item(i); + Element keyDescriptorEl = (Element) keyDescriptor; + KeyInfo ki = processKeyDescriptor(keyDescriptorEl); + if (ki != null) { + String use = keyDescriptorEl.getAttribute(JBossSAMLConstants.USE.get()); + res.add(use, ki); + } + } + } catch (SAXException | IOException | ParserConfigurationException | MarshalException | XPathExpressionException e) { + throw new ParsingException("Error parsing SAML descriptor", e); + } + + return res; + } + + private KeyInfo processKeyDescriptor(Element keyDescriptor) throws MarshalException { + NodeList childNodes = keyDescriptor.getElementsByTagNameNS(JBossSAMLURIConstants.XMLDSIG_NSURI.get(), JBossSAMLConstants.KEY_INFO.get()); + + if (childNodes.getLength() == 0) { + return null; + } + Node keyInfoNode = childNodes.item(0); + return (keyInfoNode == null) ? null : kif.unmarshalKeyInfo(new DOMStructure(keyInfoNode)); + } + +} diff --git a/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/rotation/SamlDescriptorPublicKeyLocator.java b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/rotation/SamlDescriptorPublicKeyLocator.java new file mode 100644 index 0000000000..7a45fb790c --- /dev/null +++ b/adapters/saml/core/src/main/java/org/keycloak/adapters/saml/rotation/SamlDescriptorPublicKeyLocator.java @@ -0,0 +1,175 @@ +/* + * 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.adapters.saml.rotation; + +import java.security.Key; +import java.security.KeyManagementException; +import java.security.PublicKey; +import java.security.cert.X509Certificate; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; +import javax.xml.crypto.dsig.keyinfo.KeyName; +import org.apache.http.client.HttpClient; +import org.jboss.logging.Logger; +import org.keycloak.adapters.cloned.HttpAdapterUtils; +import org.keycloak.adapters.cloned.HttpClientAdapterException; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.common.util.Time; +import org.keycloak.dom.saml.v2.metadata.KeyTypes; +import org.keycloak.rotation.KeyLocator; +import org.keycloak.saml.processing.api.util.KeyInfoTools; + +/** + * This class defines a {@link KeyLocator} that looks up public keys and certificates in IdP's + * SAML descriptor (i.e. http://{host}/auth/realms/{realm}/protocol/saml/descriptor). + * + * Based on {@code JWKPublicKeyLocator}. + * + * @author hmlnarik + */ +public class SamlDescriptorPublicKeyLocator implements KeyLocator, Iterable { + + private static final Logger LOG = Logger.getLogger(SamlDescriptorPublicKeyLocator.class); + + /** + * Time between two subsequent requests (in seconds). + */ + private final int minTimeBetweenDescriptorRequests; + + /** + * Time to live for cache entries (in seconds). + */ + private final int cacheEntryTtl; + + /** + * Target descriptor URL. + */ + private final String descriptorUrl; + + private final Map publicKeyCache = new ConcurrentHashMap<>(); + + private final HttpClient client; + + private volatile int lastRequestTime = 0; + + public SamlDescriptorPublicKeyLocator(String descriptorUrl, int minTimeBetweenDescriptorRequests, int cacheEntryTtl, HttpClient httpClient) { + this.minTimeBetweenDescriptorRequests = minTimeBetweenDescriptorRequests <= 0 + ? 20 + : minTimeBetweenDescriptorRequests; + + this.descriptorUrl = descriptorUrl; + this.cacheEntryTtl = cacheEntryTtl; + + this.client = httpClient; + } + + @Override + public Key getKey(String kid) throws KeyManagementException { + if (kid == null) { + LOG.debugf("Invalid key id: %s", kid); + return null; + } + + LOG.tracef("Requested key id: %s", kid); + + int currentTime = Time.currentTime(); + + PublicKey res; + if (currentTime > this.lastRequestTime + this.cacheEntryTtl) { + LOG.debugf("Performing regular cache cleanup."); + res = refreshCertificateCacheAndGet(kid); + } else { + res = publicKeyCache.get(kid); + + if (res == null) { + if (currentTime > this.lastRequestTime + this.minTimeBetweenDescriptorRequests) { + res = refreshCertificateCacheAndGet(kid); + } else { + LOG.debugf("Won't send request to realm SAML descriptor url, timeout not expired. Last request time was %d", lastRequestTime); + } + } + } + + return res; + } + + @Override + public synchronized void refreshKeyCache() { + LOG.info("Forcing key cache cleanup and refresh."); + this.publicKeyCache.clear(); + refreshCertificateCacheAndGet(null); + } + + private synchronized PublicKey refreshCertificateCacheAndGet(String kid) { + if (this.descriptorUrl == null) { + return null; + } + + this.lastRequestTime = Time.currentTime(); + + LOG.debugf("Refreshing public key cache from %s", this.descriptorUrl); + List signingCerts; + try { + MultivaluedHashMap certs = HttpAdapterUtils.downloadKeysFromSamlDescriptor(client, this.descriptorUrl); + signingCerts = certs.get(KeyTypes.SIGNING.value()); + } catch (HttpClientAdapterException ex) { + LOG.error("Could not refresh certificates from the server", ex); + return null; + } + + if (signingCerts == null) { + return null; + } + + LOG.debugf("Certificates retrieved from server, filling public key cache"); + + // Only clear cache after it is certain that the SAML descriptor has been read successfully + this.publicKeyCache.clear(); + + for (KeyInfo ki : signingCerts) { + KeyName keyName = KeyInfoTools.getKeyName(ki); + X509Certificate x509certificate = KeyInfoTools.getX509Certificate(ki); + if (x509certificate != null && keyName != null) { + LOG.tracef("Registering signing certificate %s", keyName.getName()); + this.publicKeyCache.put(keyName.getName(), x509certificate.getPublicKey()); + } else { + LOG.tracef("Ignoring certificate %s: %s", keyName, x509certificate); + } + + } + + return (kid == null ? null : this.publicKeyCache.get(kid)); + } + + @Override + public String toString() { + return "Keys retrieved from SAML descriptor at " + descriptorUrl; + } + + @Override + public Iterator iterator() { + if (this.publicKeyCache.isEmpty()) { + refreshCertificateCacheAndGet(null); + } + + return this.publicKeyCache.values().iterator(); + } +} diff --git a/common/src/main/java/org/keycloak/common/util/Time.java b/common/src/main/java/org/keycloak/common/util/Time.java index ef5d174a7e..54809d8dab 100644 --- a/common/src/main/java/org/keycloak/common/util/Time.java +++ b/common/src/main/java/org/keycloak/common/util/Time.java @@ -26,26 +26,51 @@ public class Time { private static int offset; + /** + * Returns current time in seconds adjusted by adding {@link #offset) seconds. + * @return see description + */ public static int currentTime() { return ((int) (System.currentTimeMillis() / 1000)) + offset; } + /** + * Returns current time in milliseconds adjusted by adding {@link #offset) seconds. + * @return see description + */ public static long currentTimeMillis() { return System.currentTimeMillis() + (offset * 1000); } + /** + * Returns {@link Date} object, its value set to time + * @param time Time in milliseconds since the epoch + * @return see description + */ public static Date toDate(int time) { return new Date(((long) time ) * 1000); } + /** + * Returns time in milliseconds for a time in seconds. No adjustment is made to the parameter. + * @param time Time in seconds since the epoch + * @return Time in milliseconds + */ public static long toMillis(int time) { return ((long) time) * 1000; } + /** + * @return Time offset in seconds that will be added to {@link #currentTime()} and {@link #currentTimeMillis()}. + */ public static int getOffset() { return offset; } + /** + * Sets time offset in seconds that will be added to {@link #currentTime()} and {@link #currentTimeMillis()}. + * @param offset Offset (in seconds) + */ public static void setOffset(int offset) { Time.offset = offset; } diff --git a/saml-core/src/main/java/org/keycloak/saml/processing/api/util/KeyInfoTools.java b/saml-core/src/main/java/org/keycloak/saml/processing/api/util/KeyInfoTools.java new file mode 100644 index 0000000000..be9bf51ddb --- /dev/null +++ b/saml-core/src/main/java/org/keycloak/saml/processing/api/util/KeyInfoTools.java @@ -0,0 +1,60 @@ +/* + * 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.saml.processing.api.util; + +import java.security.cert.X509Certificate; +import javax.xml.crypto.dsig.keyinfo.KeyInfo; +import javax.xml.crypto.dsig.keyinfo.KeyName; +import javax.xml.crypto.dsig.keyinfo.X509Data; + +/** + * Tools for {@link KeyInfo} object manipulation. + * @author hmlnarik + */ +public class KeyInfoTools { + + /** + * Returns the first object of the given class from the given Iterable. + * @param + * @param objects + * @param clazz + * @return The object or {@code null} if not found. + */ + public static T getContent(Iterable objects, Class clazz) { + for (Object o : objects) { + if (clazz.isInstance(o)) { + return (T) o; + } + } + return null; + } + + + public static KeyName getKeyName(KeyInfo keyInfo) { + return getContent(keyInfo.getContent(), KeyName.class); + } + + public static X509Data getX509Data(KeyInfo keyInfo) { + return getContent(keyInfo.getContent(), X509Data.class); + } + + public static X509Certificate getX509Certificate(KeyInfo keyInfo) { + X509Data d = getX509Data(keyInfo); + return d == null ? null : getContent(d.getContent(), X509Certificate.class); + } + +}