From 2cb7ec9432e0b2f76b947016dbca8067d7a18108 Mon Sep 17 00:00:00 2001
From: Grzegorz Grzybek
Date: Tue, 26 Jun 2018 18:54:06 +0200
Subject: [PATCH] [KEYCLOAK-7703] HierarchicalPathBasedKeycloakConfigResolver
for more fine/coarse grained Keycloak configuration in Karaf
---
...chicalPathBasedKeycloakConfigResolver.java | 89 ++++++++++
.../osgi/PathBasedKeycloakConfigResolver.java | 140 ++++++++++++---
...alPathBasedKeycloakConfigResolverTest.java | 162 ++++++++++++++++++
3 files changed, 365 insertions(+), 26 deletions(-)
create mode 100644 adapters/oidc/osgi-adapter/src/main/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolver.java
create mode 100644 adapters/oidc/osgi-adapter/src/test/java/org/keycloak/adapters/osgi/HierarchicalPathBasedKeycloakConfigResolverTest.java
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 0000000000..06d01215d9
--- /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,
+ * parent 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 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);
+ }
+
+ /**
+ * For segments like "a, b, c, d", returns:
+ * - "a-b-c-d"
+ * - "a-b-c"
+ * - "a-b"
+ * - "a"
+ * - ""
+ *
+ * @param segments
+ * @return
+ */
+ private List collectPaths(String[] segments) {
+ List 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 e280f419c1..a2eaba9271 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 cache = new ConcurrentHashMap();
+ 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 -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 0000000000..aecbfc90b3
--- /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 cache = (Map) 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 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) {
+
+ }
+ }
+
+}