keycloak-memoizeit

[KEYCLOAK-7703] HierarchicalPathBasedKeycloakConfigResolver

6/26/2018 1:54:06 PM

Details

diff --git a/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java
new file mode 100644
index 0000000..06d0121
--- /dev/null
+++ b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java
@@ -0,0 +1,89 @@
+/*
+ * 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.osgi;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jboss.logging.Logger;
+import org.keycloak.adapters.KeycloakConfigResolver;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.OIDCHttpFacade;
+
+/**
+ * This {@link KeycloakConfigResolver} tries to resolve most specific configuration for given URI path. If not found,
+ * <em>parent</em> path is checked up to top-level path.
+ */
+public class HierarchicalPathBasedKeycloakConfigResolver extends PathBasedKeycloakConfigResolver {
+
+    protected static final Logger log = Logger.getLogger(HierarchicalPathBasedKeycloakConfigResolver.class);
+
+    public HierarchicalPathBasedKeycloakConfigResolver() {
+        prepopulateCache();
+    }
+
+    @Override
+    public KeycloakDeployment resolve(OIDCHttpFacade.Request request) {
+        // we cached all available deployments initially and now we'll try to check them from
+        // most specific to most general
+        URI uri = URI.create(request.getURI());
+        String path = uri.getPath();
+        if (path != null) {
+            while (path.startsWith("/")) {
+                path = path.substring(1);
+            }
+            String[] segments = path.split("/");
+            List<String> paths = collectPaths(segments);
+            for (String pathFragment: paths) {
+                KeycloakDeployment cachedDeployment = super.getCachedDeployment(pathFragment);
+                if (cachedDeployment != null) {
+                    return cachedDeployment;
+                }
+            }
+        }
+
+        throw new IllegalStateException("Can't find Keycloak configuration related to URI path " + uri);
+    }
+
+    /**
+     * <p>For segments like "a, b, c, d", returns:<ul>
+     *     <li>"a-b-c-d"</li>
+     *     <li>"a-b-c"</li>
+     *     <li>"a-b"</li>
+     *     <li>"a"</li>
+     *     <li>""</li>
+     * </ul></p>
+     * @param segments
+     * @return
+     */
+    private List<String> collectPaths(String[] segments) {
+        List<String> result = new ArrayList<>(segments.length + 1);
+        for (int idx = segments.length; idx >= 0; idx--) {
+            StringBuilder sb = null;
+            for (int i = 0; i < idx; i++) {
+                if (sb == null) {
+                    sb = new StringBuilder();
+                }
+                sb.append("-").append(segments[i]);
+            }
+            result.add(sb == null ? "" : sb.toString().substring(1));
+        }
+        return result;
+    }
+
+}
diff --git a/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java
index e280f41..a2eaba9 100644
--- a/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java
+++ b/adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/PathBasedKeycloakConfigResolver.java
@@ -16,12 +16,15 @@
  */
 package org.keycloak.adapters.osgi;
 
+import org.jboss.logging.Logger;
 import org.keycloak.adapters.KeycloakConfigResolver;
 import org.keycloak.adapters.KeycloakDeployment;
 import org.keycloak.adapters.KeycloakDeploymentBuilder;
 import org.keycloak.adapters.OIDCHttpFacade;
+import org.keycloak.adapters.spi.HttpFacade;
 
 import java.io.File;
+import java.io.FileFilter;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.InputStream;
@@ -31,10 +34,117 @@ import java.util.concurrent.ConcurrentHashMap;
 
 public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver {
 
+    protected static final Logger log = Logger.getLogger(PathBasedKeycloakConfigResolver.class);
+
     private final Map<String, KeycloakDeployment> cache = new ConcurrentHashMap<String, KeycloakDeployment>();
 
+    private File keycloakConfigLocation = null;
+
+    public PathBasedKeycloakConfigResolver() {
+        String location = null;
+        String keycloakConfig = (String) System.getProperties().get("keycloak.config");
+        if (keycloakConfig != null && !"".equals(keycloakConfig.trim())) {
+            location = keycloakConfig;
+        } else {
+            String karafEtc = (String) System.getProperties().get("karaf.etc");
+            if (karafEtc != null && !"".equals(karafEtc.trim())) {
+                location = karafEtc;
+            }
+        }
+        if (location != null) {
+            File loc = new File(location);
+            if (loc.isDirectory()) {
+                keycloakConfigLocation = loc;
+            }
+        }
+    }
+
     @Override
     public KeycloakDeployment resolve(OIDCHttpFacade.Request request) {
+        String webContext = getDeploymentKeyForURI(request);
+
+        return getOrCreateDeployment(webContext);
+    }
+
+    /**
+     * {@code pathFragment} is a key for {@link KeycloakDeployment deployments}. The key is used to construct
+     * a path relative to {@code keycloak.config} or {@code karaf.etc} system properties.
+     * For given key, {@code <key>-keycloak.json} file is checked.
+     * @param pathFragment
+     * @return
+     */
+    protected synchronized KeycloakDeployment getOrCreateDeployment(String pathFragment) {
+        KeycloakDeployment deployment = getCachedDeployment(pathFragment);
+        if (null == deployment) {
+            // not found on the simple cache, try to load it from the file system
+            if (keycloakConfigLocation == null) {
+                throw new IllegalStateException("Neither \"keycloak.config\" nor \"karaf.etc\" java properties are set." +
+                        " Please set one of them.");
+            }
+
+            File configuration = new File(keycloakConfigLocation, pathFragment + ("".equals(pathFragment) ? "" : "-")
+                    + "keycloak.json");
+            if (!cacheConfiguration(pathFragment, configuration)) {
+                throw new IllegalStateException("Not able to read the file " + configuration);
+            }
+        }
+
+        return deployment;
+    }
+
+    protected synchronized KeycloakDeployment getCachedDeployment(String pathFragment) {
+        return cache.get(pathFragment);
+    }
+
+    /**
+     * If there's a need, we can pre populate the cache of deployments.
+     */
+    protected void prepopulateCache() {
+        if (keycloakConfigLocation == null || !keycloakConfigLocation.isDirectory()) {
+            log.warn("Can't cache Keycloak configurations. No configuration storage is accessible." +
+                    " Please set either \"keycloak.config\" or \"karaf.etc\" system properties");
+            return;
+        }
+
+        File[] configs = keycloakConfigLocation.listFiles(new FileFilter() {
+            @Override
+            public boolean accept(File pathname) {
+                return pathname.isFile() && pathname.getName().endsWith("keycloak.json");
+            }
+        });
+        if (configs != null) {
+            for (File config: configs) {
+                String pathFragment = null;
+                if ("keycloak.json".equals(config.getName())) {
+                    pathFragment = "";
+                } else if (config.getName().endsWith("-keycloak.json")) {
+                    pathFragment = config.getName()
+                            .substring(0, config.getName().length() - "-keycloak.json".length());
+                }
+                cacheConfiguration(pathFragment, config);
+            }
+        }
+    }
+
+    private boolean cacheConfiguration(String key, File config) {
+        try {
+            InputStream is = new FileInputStream(config);
+            KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(is);
+            cache.put(key, deployment);
+            return true;
+        } catch (FileNotFoundException | RuntimeException e) {
+            log.warn("Can't cache " + config + ": " + e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * Finds a context path from given {@link HttpFacade.Request}. For default context, first path segment
+     * is returned.
+     * @param request
+     * @return
+     */
+    private String getDeploymentKeyForURI(HttpFacade.Request request) {
         String uri = request.getURI();
         String relativePath = request.getRelativePath();
         String webContext = null;
@@ -48,7 +158,9 @@ public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver {
         } else {
             URI parsedURI = URI.create(uri);
             String path = parsedURI.getPath();
-            path = path.substring(0, path.indexOf(relativePath));
+            if (path.contains(relativePath)) {
+                path = path.substring(0, path.indexOf(relativePath));
+            }
             while (path.startsWith("/")) {
                 path = path.substring(1);
             }
@@ -65,31 +177,7 @@ public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver {
             }
         }
 
-        KeycloakDeployment deployment = cache.get(webContext);
-        if (null == deployment) {
-            // not found on the simple cache, try to load it from the file system
-            String keycloakConfig = (String) System.getProperties().get("keycloak.config");
-            if(keycloakConfig == null || "".equals(keycloakConfig.trim())){
-                String karafEtc = (String) System.getProperties().get("karaf.etc");
-                if(karafEtc == null || "".equals(karafEtc.trim())){
-                    throw new IllegalStateException("Neither \"keycloak.config\" nor \"karaf.etc\" java properties are set. Please set one of them.");
-                }
-                keycloakConfig = karafEtc;
-            }
-
-            String absolutePath = keycloakConfig + File.separator + webContext + ("".equals(webContext) ? "" : "-")
-                    + "keycloak.json";
-            InputStream is = null;
-            try {
-                is = new FileInputStream(absolutePath);
-            } catch (FileNotFoundException e){
-                throw new IllegalStateException("Not able to find the file " + absolutePath);
-            }
-            deployment = KeycloakDeploymentBuilder.build(is);
-            cache.put(webContext, deployment);
-        }
-
-        return deployment;
+        return webContext;
     }
 
 }
diff --git a/adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java b/adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java
new file mode 100644
index 0000000..aecbfc9
--- /dev/null
+++ b/adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright 2018 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.osgi;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Test;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.OIDCHttpFacade;
+import org.keycloak.adapters.spi.AuthenticationError;
+import org.keycloak.adapters.spi.HttpFacade;
+import org.keycloak.adapters.spi.LogoutError;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.fail;
+
+public class HierarchicalPathBasedKeycloakConfigResolverTest {
+
+    @Test
+    public void genericAndSpecificConfigurations() throws Exception {
+        HierarchicalPathBasedKeycloakConfigResolver resolver = new HierarchicalPathBasedKeycloakConfigResolver();
+        populate(resolver, true);
+
+        assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/c/d/e?a=b")).getRealm(), equalTo("a-b-c-d-e"));
+        assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/c/d/x?a=b")).getRealm(), equalTo("a-b-c-d"));
+        assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/c/x/x?a=b")).getRealm(), equalTo("a-b-c"));
+        assertThat(resolver.resolve(new MockRequest("http://localhost/a/b/x/x/x?a=b")).getRealm(), equalTo("a-b"));
+        assertThat(resolver.resolve(new MockRequest("http://localhost/a/x/x/x/x?a=b")).getRealm(), equalTo("a"));
+        assertThat(resolver.resolve(new MockRequest("http://localhost/x/x/x/x/x?a=b")).getRealm(), equalTo(""));
+
+        populate(resolver, false);
+        try {
+            resolver.resolve(new MockRequest("http://localhost/x/x/x/x/x?a=b"));
+            fail("Expected java.lang.IllegalStateException: Can't find Keycloak configuration ...");
+        } catch (IllegalStateException expected) {
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private PathBasedKeycloakConfigResolver populate(PathBasedKeycloakConfigResolver resolver, boolean fallback)
+            throws Exception {
+        Field f = PathBasedKeycloakConfigResolver.class.getDeclaredField("cache");
+        f.setAccessible(true);
+        Map<String, KeycloakDeployment> cache = (Map<String, KeycloakDeployment>) f.get(resolver);
+        cache.clear();
+        cache.put("a-b-c-d-e", newKeycloakDeployment("a-b-c-d-e"));
+        cache.put("a-b-c-d", newKeycloakDeployment("a-b-c-d"));
+        cache.put("a-b-c", newKeycloakDeployment("a-b-c"));
+        cache.put("a-b", newKeycloakDeployment("a-b"));
+        cache.put("a", newKeycloakDeployment("a"));
+        if (fallback) {
+            cache.put("", newKeycloakDeployment(""));
+        }
+
+        return resolver;
+    }
+
+    private KeycloakDeployment newKeycloakDeployment(String realm) {
+        KeycloakDeployment deployment = new KeycloakDeployment();
+        deployment.setRealm(realm);
+
+        return deployment;
+    }
+
+    private class MockRequest implements OIDCHttpFacade.Request {
+
+        private String uri;
+
+        public MockRequest(String uri) {
+            this.uri = uri;
+        }
+
+        @Override
+        public String getMethod() {
+            return null;
+        }
+
+        @Override
+        public String getURI() {
+            return this.uri;
+        }
+
+        @Override
+        public String getRelativePath() {
+            return null;
+        }
+
+        @Override
+        public boolean isSecure() {
+            return false;
+        }
+
+        @Override
+        public String getFirstParam(String param) {
+            return null;
+        }
+
+        @Override
+        public String getQueryParamValue(String param) {
+            return null;
+        }
+
+        @Override
+        public HttpFacade.Cookie getCookie(String cookieName) {
+            return null;
+        }
+
+        @Override
+        public String getHeader(String name) {
+            return null;
+        }
+
+        @Override
+        public List<String> getHeaders(String name) {
+            return null;
+        }
+
+        @Override
+        public InputStream getInputStream() {
+            return null;
+        }
+
+        @Override
+        public InputStream getInputStream(boolean buffered) {
+            return null;
+        }
+
+        @Override
+        public String getRemoteAddr() {
+            return null;
+        }
+
+        @Override
+        public void setError(AuthenticationError error) {
+
+        }
+
+        @Override
+        public void setError(LogoutError error) {
+
+        }
+    }
+
+}