diff --git a/.gitignore b/.gitignore index e9d50ff8be..319769bf2a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ .settings .classpath + +# NetBeans # +############ +nb-configuration.xml + # Compiled source # ################### *.com diff --git a/core/src/main/java/org/keycloak/KeycloakSecurityContext.java b/core/src/main/java/org/keycloak/KeycloakSecurityContext.java index 4658c68157..e9809e3753 100755 --- a/core/src/main/java/org/keycloak/KeycloakSecurityContext.java +++ b/core/src/main/java/org/keycloak/KeycloakSecurityContext.java @@ -17,6 +17,7 @@ import java.io.Serializable; public class KeycloakSecurityContext implements Serializable { protected String tokenString; protected String idTokenString; + protected String realm; // Don't store parsed tokens into HTTP session protected transient AccessToken token; @@ -25,11 +26,12 @@ public class KeycloakSecurityContext implements Serializable { public KeycloakSecurityContext() { } - public KeycloakSecurityContext(String tokenString, AccessToken token, String idTokenString, IDToken idToken) { + public KeycloakSecurityContext(String tokenString, AccessToken token, String idTokenString, IDToken idToken, String realm) { this.tokenString = tokenString; this.token = token; this.idToken = idToken; this.idTokenString = idTokenString; + this.realm = realm; } public AccessToken getToken() { @@ -48,6 +50,9 @@ public class KeycloakSecurityContext implements Serializable { return idTokenString; } + public String getRealm() { + return realm; + } // SERIALIZATION diff --git a/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java b/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java index f3a89b6d1a..951a6a0d1d 100755 --- a/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java +++ b/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java @@ -56,6 +56,7 @@ public class SkeletonKeyTokenTest { @Test public void testSerialization() throws Exception { + String realm = "acme"; AccessToken token = createSimpleToken(); IDToken idToken = new IDToken(); idToken.setEmail("joe@email.cz"); @@ -69,7 +70,7 @@ public class SkeletonKeyTokenTest { .jsonContent(idToken) .rsa256(keyPair.getPrivate()); - KeycloakSecurityContext ctx = new KeycloakSecurityContext(encoded, token, encodedIdToken, idToken); + KeycloakSecurityContext ctx = new KeycloakSecurityContext(encoded, token, encodedIdToken, idToken, realm); KeycloakPrincipal principal = new KeycloakPrincipal("joe", ctx); // Serialize @@ -96,6 +97,7 @@ public class SkeletonKeyTokenTest { Assert.assertTrue(token.getResourceAccess("foo").isUserInRole("admin")); Assert.assertTrue(token.getResourceAccess("bar").isUserInRole("user")); Assert.assertEquals("joe@email.cz", idToken.getEmail()); + Assert.assertEquals("acme", ctx.getRealm()); ois.close(); } diff --git a/distribution/examples-docs-zip/build.xml b/distribution/examples-docs-zip/build.xml index 3e4f1dcf52..a33d88d58c 100755 --- a/distribution/examples-docs-zip/build.xml +++ b/distribution/examples-docs-zip/build.xml @@ -42,6 +42,14 @@ + + + + + + + + diff --git a/docbook/reference/en/en-US/master.xml b/docbook/reference/en/en-US/master.xml index 6a704151ec..db6dead9ce 100755 --- a/docbook/reference/en/en-US/master.xml +++ b/docbook/reference/en/en-US/master.xml @@ -36,6 +36,7 @@ + ]> @@ -88,6 +89,7 @@ This one is short &JavascriptAdapter; &InstalledApplications; &Logout; + &MultiTenancy; diff --git a/docbook/reference/en/en-US/modules/multi-tenancy.xml b/docbook/reference/en/en-US/modules/multi-tenancy.xml new file mode 100644 index 0000000000..410621f049 --- /dev/null +++ b/docbook/reference/en/en-US/modules/multi-tenancy.xml @@ -0,0 +1,56 @@ +
+ Multi Tenancy + + Multi Tenancy, in our context, means that one single target application (WAR) can be secured by a single (or clustered) Keycloak server, authenticating + its users against different realms. In practice, this means that one application needs to use different keycloak.json files. + For this case, there are two possible solutions: + + + + The same WAR file deployed under two different names, each with its own Keycloak configuration (probably via the Keycloak Subsystem). + This scenario is suitable when the number of realms is known in advance or when there's a dynamic provision of application instances. + One example would be a service provider that dinamically creates servers/deployments for their clients, like a PaaS. + + + + A WAR file deployed once (possibly in a cluster), that decides which realm to authenticate against based on the request parameters. + This scenario is suitable when there are an undefined number of realms. One example would be a SaaS provider that have only one deployment + (perhaps in a cluster) serving several companies, differentiating between clients based on the hostname + (client1.acme.com, client2.acme.com) or path (/app/client1/, + /app/client2/) or even via a special HTTP Header. + + + + + This chapter of the reference guide focus on this second scenario. + + + + Keycloak provides an extension point for applications that need to evaluate the realm on a request basis. During the authentication + and authorization phase of the incoming request, Keycloak queries the application via this extension point and expects the application + to return a complete representation of the realm. With this, Keycloak then proceeds the authentication and authorization process, + accepting or refusing the request based on the incoming credentials and on the returned realm. + + For this scenario, an application needs to: + + + + + Add a context parameter to the web.xml, named keycloak.config.resolver. + The value of this property should be the fully qualified name of the a class extending + org.keycloak.adapters.KeycloakConfigResolver. + + + + A concrete implementation of org.keycloak.adapters.KeycloakConfigResolver. Keycloak will call the + resolve(org.keycloak.adapters.HttpFacade.Request) method and expects a complete + org.keycloak.adapters.KeycloakDeployment in response. Note that Keycloak will call this for every request, + so, take the usual performance precautions. + + + + + + An implementation of this feature can be found on the examples. + +
diff --git a/examples/README.md b/examples/README.md index 74970e937a..cc4235483d 100755 --- a/examples/README.md +++ b/examples/README.md @@ -46,3 +46,9 @@ Themes ------ Example themes to change the look and feel of login forms, account management console and admin console. For more information look at `themes/README.md`. + + +Multi tenancy +------------- + +A complete application, showing how to achieve multi tenancy of web applications by using one realm per account. For more information look at `multi-tenant/README.md` diff --git a/examples/cors/pom.xml b/examples/cors/pom.xml index 0317ffa82a..9b1c0487ee 100755 --- a/examples/cors/pom.xml +++ b/examples/cors/pom.xml @@ -6,7 +6,7 @@ 1.1.0-Alpha1-SNAPSHOT ../../pom.xml - Examples + Keycloak Examples - CORS 4.0.0 diff --git a/examples/multi-tenant/README.md b/examples/multi-tenant/README.md new file mode 100644 index 0000000000..71c9b8c7ea --- /dev/null +++ b/examples/multi-tenant/README.md @@ -0,0 +1,32 @@ +Keycloak Example - Multi Tenancy +======================================= + +The following example was tested on Wildfly 8.1.0.Final and should be compatible with any JBoss AS, JBoss EAP or Wildfly that supports Java EE 7. + +This example demonstrates the simplest possible scenario for Keycloak Multi Tenancy support. Multi Tenancy is understood on this context as a single application (WAR) that is deployed on a single or clustered application server, authenticating users from *different realms* against a single or clustered Keycloak server. + +The multi tenancy is achieved by having one realm per tenant on the server side and a per-request decision on which realm to authenticate the request against. + +This example contains only the minimal bits required for a multi tenant application. + +This example is composed of the following parts: + +- ProtectedServlet - A servlet that displays the username and realm from the current user +- PathBasedKeycloakConfigResolver - A configuration resolver that takes the realm based on the path: /simple-multitenant/tenant2 means that the realm is "tenant2". + +Step 1: Setup a basic Keycloak server +-------------------------------------------------------------- +Install Keycloak server and start it on port 8080. Check the Reference Guide if unsure on how to do it. + +Once the Keycloak server is up and running, import the two realms from "src/main/resources/", namely: + +- tenant1-realm.json +- tenant2-realm.json + +Step 2: Deploy and run the example +-------------------------------------------------------------- + +- Build and deploy this sample's WAR file. For this example, deploy on the same server that is running the Keycloak Server, although this is not required for real world scenarios. +- Access [http://localhost:8080/multitenant/tenant1](http://localhost:8080/multitenant/tenant1) and login as ``user-tenant1``, password ``user-tenant1`` +- Access [http://localhost:8080/multitenant/tenant2](http://localhost:8080/multitenant/tenant2) and login as ``user-tenant2``, password ``user-tenant2`` + diff --git a/examples/multi-tenant/pom.xml b/examples/multi-tenant/pom.xml new file mode 100644 index 0000000000..653632c5de --- /dev/null +++ b/examples/multi-tenant/pom.xml @@ -0,0 +1,65 @@ + + 4.0.0 + + + keycloak-parent + org.keycloak + 1.1.0-Alpha1-SNAPSHOT + ../../pom.xml + + + Keycloak Examples - Multi Tenant + multitenant + war + + + Keycloak Multi Tenants Example + + + + + + org.wildfly.bom + jboss-javaee-7.0-with-all + 8.0.0.Final + pom + import + + + + org.keycloak + keycloak-core + ${project.version} + + + org.keycloak + keycloak-adapter-core + ${project.version} + + + + + + + org.jboss.spec.javax.servlet + jboss-servlet-api_3.1_spec + provided + + + + + org.keycloak + keycloak-adapter-core + + + + + org.keycloak + keycloak-core + + + + ${project.artifactId} + + + diff --git a/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java b/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java new file mode 100644 index 0000000000..991169dc29 --- /dev/null +++ b/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java @@ -0,0 +1,61 @@ +/* + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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.example.multitenant.boundary; + +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.ServletException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.keycloak.KeycloakPrincipal; + +/** + * + * @author Juraci Paixão Kröhling + */ +@WebServlet(urlPatterns = "/*") +public class ProtectedServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + String realm = req.getPathInfo().split("/")[1]; + if (realm.contains("?")) { + realm = realm.split("\\?")[0]; + } + + if (req.getPathInfo().contains("logout")) { + req.logout(); + resp.sendRedirect(req.getContextPath() + "/" + realm); + return; + } + + KeycloakPrincipal principal = (KeycloakPrincipal) req.getUserPrincipal(); + + resp.setContentType("text/html"); + PrintWriter writer = resp.getWriter(); + + writer.write("Realm: "); + writer.write(principal.getKeycloakSecurityContext().getIdToken().getIssuer()); + + writer.write("
User: "); + writer.write(principal.getKeycloakSecurityContext().getIdToken().getPreferredUsername()); + + writer.write(String.format("
Logout", realm)); + } + } diff --git a/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/control/PathBasedKeycloakConfigResolver.java b/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/control/PathBasedKeycloakConfigResolver.java new file mode 100644 index 0000000000..4aa2fea2d0 --- /dev/null +++ b/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/control/PathBasedKeycloakConfigResolver.java @@ -0,0 +1,56 @@ +/* + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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.example.multitenant.control; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.keycloak.adapters.HttpFacade; +import org.keycloak.adapters.KeycloakConfigResolver; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; + +/** + * + * @author Juraci Paixão Kröhling + */ +public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver { + + private final Map cache = new ConcurrentHashMap(); + + @Override + public KeycloakDeployment resolve(HttpFacade.Request request) { + String path = request.getURI(); + String realm = path.substring(path.indexOf("multitenant/")).split("/")[1]; + if (realm.contains("?")) { + realm = realm.split("\\?")[0]; + } + + KeycloakDeployment deployment = cache.get(realm); + if (null == deployment) { + // not found on the simple cache, try to load it from the file system + InputStream is = getClass().getResourceAsStream("/" + realm + "-keycloak.json"); + deployment = KeycloakDeploymentBuilder.build(is); + cache.put(realm, deployment); + } + + return deployment; + } + +} diff --git a/examples/multi-tenant/src/main/resources/tenant1-keycloak.json b/examples/multi-tenant/src/main/resources/tenant1-keycloak.json new file mode 100644 index 0000000000..57be2774e7 --- /dev/null +++ b/examples/multi-tenant/src/main/resources/tenant1-keycloak.json @@ -0,0 +1,10 @@ +{ + "realm" : "tenant1", + "resource" : "multi-tenant", + "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url" : "http://localhost:8080/auth", + "ssl-required" : "external", + "credentials" : { + "secret": "password" + } +} \ No newline at end of file diff --git a/examples/multi-tenant/src/main/resources/tenant2-keycloak.json b/examples/multi-tenant/src/main/resources/tenant2-keycloak.json new file mode 100644 index 0000000000..4f221dc66d --- /dev/null +++ b/examples/multi-tenant/src/main/resources/tenant2-keycloak.json @@ -0,0 +1,10 @@ +{ + "realm" : "tenant2", + "resource" : "multi-tenant", + "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA0oJjgPQJhnVhOo51KauQGfLLreMFu64OJdKXRnfvAQJQTuKNwc5JrR63l/byyW1B6FgclABF818TtLvMCAkn4EuFwQZCZhg3x3+lFGiB/IzC6UAt4Bi0JQrTbdh83/U97GIPegvaDqiqEiQESEkbCZWxM6sh/34hQaAhCaFpMwIDAQAB", + "auth-server-url" : "http://localhost:8080/auth", + "ssl-required" : "external", + "credentials" : { + "secret": "password" + } +} \ No newline at end of file diff --git a/examples/multi-tenant/src/main/webapp/WEB-INF/jboss-ejb3.xml b/examples/multi-tenant/src/main/webapp/WEB-INF/jboss-ejb3.xml new file mode 100644 index 0000000000..1693f7acbc --- /dev/null +++ b/examples/multi-tenant/src/main/webapp/WEB-INF/jboss-ejb3.xml @@ -0,0 +1,31 @@ + + + + + + + + * + keycloak + + + \ No newline at end of file diff --git a/examples/multi-tenant/src/main/webapp/WEB-INF/web.xml b/examples/multi-tenant/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000000..adfc73f5a5 --- /dev/null +++ b/examples/multi-tenant/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,29 @@ + + Multi Tenant Example + + + keycloak.config.resolver + org.keycloak.example.multitenant.control.PathBasedKeycloakConfigResolver + + + + + REST endpoints + /* + + + * + + + + + KEYCLOAK + not-important + + + user + + \ No newline at end of file diff --git a/examples/multi-tenant/tenant1-realm.json b/examples/multi-tenant/tenant1-realm.json new file mode 100644 index 0000000000..76acce85ff --- /dev/null +++ b/examples/multi-tenant/tenant1-realm.json @@ -0,0 +1,57 @@ +{ + "id": "tenant1", + "realm": "tenant1", + "enabled": true, + "accessTokenLifespan": 3000, + "accessCodeLifespan": 10, + "accessCodeLifespanUserAction": 6000, + "sslRequired": "external", + "registrationAllowed": false, + "social": false, + "passwordCredentialGrantAllowed": true, + "updateProfileOnInitialSocialLogin": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": [ "password" ], + "users" : [ + { + "username" : "user-tenant1", + "enabled": true, + "credentials" : [ + { "type" : "password", + "value" : "user-tenant1" } + ], + "realmRoles": [ "user" ], + "applicationRoles": { + "multi-tenant": [ "user" ] + } + } + ], + "roles" : { + "realm" : [ + { + "name": "user", + "description": "User privileges" + } + ] + }, + "scopeMappings": [ + { + "client": "multi-tenant", + "roles": ["user"] + } + + ], + "applications": [ + { + "name": "multi-tenant", + "enabled": true, + "adminUrl": "http://localhost:8080/multitenant/tenant1", + "baseUrl": "http://localhost:8080/multitenant/tenant1", + "redirectUris": [ + "http://localhost:8080/multitenant/tenant1/*" + ], + "secret": "password" + } + ] +} diff --git a/examples/multi-tenant/tenant2-realm.json b/examples/multi-tenant/tenant2-realm.json new file mode 100644 index 0000000000..295cb3f4d3 --- /dev/null +++ b/examples/multi-tenant/tenant2-realm.json @@ -0,0 +1,57 @@ +{ + "id": "tenant2", + "realm": "tenant2", + "enabled": true, + "accessTokenLifespan": 3000, + "accessCodeLifespan": 10, + "accessCodeLifespanUserAction": 6000, + "sslRequired": "external", + "registrationAllowed": false, + "social": false, + "passwordCredentialGrantAllowed": true, + "updateProfileOnInitialSocialLogin": false, + "privateKey": "MIICXQIBAAKBgQDA0oJjgPQJhnVhOo51KauQGfLLreMFu64OJdKXRnfvAQJQTuKNwc5JrR63l/byyW1B6FgclABF818TtLvMCAkn4EuFwQZCZhg3x3+lFGiB/IzC6UAt4Bi0JQrTbdh83/U97GIPegvaDqiqEiQESEkbCZWxM6sh/34hQaAhCaFpMwIDAQABAoGADwFSvEOQuh0IjWRtKZjwjOo4BrmlbRDJ3rf6x2LoemTttSouXzGxx/H87fSZdxNNuU9HbBHoY4ko4POzmZEWhS0gV6UjM7VArc4YjID6Hh2tfU9vCbuuKZrRs7RjxL70b51WxycKc49PQ4JiR3g04punrpq2UzToPrm66zI+ICECQQD2Jauo6cXXoxHR0QychQf4dityZwFXUoR/8oI/YFiu9XwcWgSMwrFKUdWWNKYmrIRNqCBzrGyeiGdaAjsw41T3AkEAyIpn+XL7bek/uLno5/7ULauf2dFI6MEaHJixQJD7S6Tfo/CGuDK93H4K0GAdjgR0LA0tCnB09yyPCd5NmAYKpQJBAO7+BH4s/PsyScr+vs/6GpMTqXuap6KxbBUO0YfXdEPr9mVQwboqDxmp+0esNua1+n+sDlZBw/TpW+/42p/NGmECQF0sOQyjyH+TfGCmN7j6I7ioYZeA7h/9/9TDeK8n7SmDC8kOanlQUfgMs5eG4JRoK1WANaoA/8cLc9XA7EoynGUCQQDx/Gjg6qyWheVujxjKufH1XkqDNiQHClDRM1ntChCmGq/RmpVmce+mYeOYZ9eofv7UJUCBdamllRlB+056Ld2h", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA0oJjgPQJhnVhOo51KauQGfLLreMFu64OJdKXRnfvAQJQTuKNwc5JrR63l/byyW1B6FgclABF818TtLvMCAkn4EuFwQZCZhg3x3+lFGiB/IzC6UAt4Bi0JQrTbdh83/U97GIPegvaDqiqEiQESEkbCZWxM6sh/34hQaAhCaFpMwIDAQAB", + "requiredCredentials": [ "password" ], + "users" : [ + { + "username" : "user-tenant2", + "enabled": true, + "credentials" : [ + { "type" : "password", + "value" : "user-tenant2" } + ], + "realmRoles": [ "user" ], + "applicationRoles": { + "multi-tenant": [ "user" ] + } + } + ], + "roles" : { + "realm" : [ + { + "name": "user", + "description": "User privileges" + } + ] + }, + "scopeMappings": [ + { + "client": "multi-tenant", + "roles": ["user"] + } + + ], + "applications": [ + { + "name": "multi-tenant", + "enabled": true, + "adminUrl": "http://localhost:8080/multitenant/tenant2", + "baseUrl": "http://localhost:8080/multitenant/tenant2", + "redirectUris": [ + "http://localhost:8080/multitenant/tenant2/*" + ], + "secret": "password" + } + ] +} diff --git a/examples/pom.xml b/examples/pom.xml index ea6fac8122..7ec8a316fd 100755 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -29,5 +29,6 @@ demo-template providers js-console + multi-tenant diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterDeploymentContext.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterDeploymentContext.java index 9fff2b26c8..9c107ae8c4 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterDeploymentContext.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterDeploymentContext.java @@ -27,27 +27,54 @@ import java.util.Map; public class AdapterDeploymentContext { private static final Logger log = Logger.getLogger(AdapterDeploymentContext.class); protected KeycloakDeployment deployment; + protected KeycloakConfigResolver configResolver; public AdapterDeploymentContext() { } + /** + * For single-tenant deployments, this constructor is to be used, as a + * full KeycloakDeployment is known at deployment time and won't change + * during the application deployment's life cycle. + * + * @param deployment A KeycloakConfigResolver, possibly missing the Auth + * Server URL and/or Realm Public Key + */ public AdapterDeploymentContext(KeycloakDeployment deployment) { this.deployment = deployment; } - public KeycloakDeployment getDeployment() { - return deployment; + /** + * For multi-tenant deployments, this constructor is to be used, as a + * KeycloakDeployment is not known at deployment time. It defers the + * resolution of a KeycloakDeployment to a KeycloakConfigResolver, + * to be implemented by the target application. + * + * @param configResolver A KeycloakConfigResolver that will be used + * to resolve a KeycloakDeployment + */ + public AdapterDeploymentContext(KeycloakConfigResolver configResolver) { + this.configResolver = configResolver; } /** - * Resolve adapter deployment based on partial adapter configuration. - * This will resolve a relative auth server url based on the current request - * This will lazily resolve the public key of the realm if it is not set already. + * For single-tenant deployments, it complements KeycloakDeployment + * by resolving a relative Auth Server's URL based on the current request + * and, if needed, will lazily resolve the Realm's Public Key. * + * For multi-tenant deployments, defers the resolution of KeycloakDeployment + * to the KeycloakConfigResolver . + * + * @param facade the Request/Response Façade , used to either determine + * the Auth Server URL (single tenant) or pass thru to the + * KeycloakConfigResolver. * @return */ public KeycloakDeployment resolveDeployment(HttpFacade facade) { - KeycloakDeployment deployment = this.deployment; + if (null != configResolver) { + return configResolver.resolve(facade.getRequest()); + } + if (deployment == null) return null; if (deployment.getAuthServerBaseUrl() == null) return deployment; @@ -411,6 +438,9 @@ public class AdapterDeploymentContext { } public void updateDeployment(AdapterConfig config) { + if (null != configResolver) { + throw new IllegalStateException("Cannot parse an adapter config and build an updated deployment when on a multi-tenant scenario."); + } deployment = KeycloakDeploymentBuilder.build(config); } } diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/KeycloakConfigResolver.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/KeycloakConfigResolver.java new file mode 100644 index 0000000000..8ba41437e2 --- /dev/null +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/KeycloakConfigResolver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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; + +import org.keycloak.adapters.HttpFacade.Request; + +/** + * On multi-tenant scenarios, Keycloak will defer the resolution of a + * KeycloakDeployment to the target application at the request-phase. + * + * A Request object is passed to the resolver and callers expect a complete + * KeycloakDeployment. Based on this KeycloakDeployment, Keycloak will resume + * authenticating and authorizing the request. + * + * The easiest way to build a KeycloakDeployment is to use + * KeycloakDeploymentBuilder , passing the InputStream of an existing + * keycloak.json to the build() method. + * + * @see KeycloakDeploymentBuilder + * @author Juraci Paixão Kröhling + */ +public interface KeycloakConfigResolver { + + public KeycloakDeployment resolve(Request facade); + +} diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java index aea33dd305..099697f398 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/PreAuthActionsHandler.java @@ -67,7 +67,7 @@ public class PreAuthActionsHandler { public boolean preflightCors() { // don't need to resolve deployment on cors requests. Just need to know local cors config. - KeycloakDeployment deployment = deploymentContext.getDeployment(); + KeycloakDeployment deployment = deploymentContext.resolveDeployment(facade); if (!deployment.isCors()) return false; log.debugv("checkCorsPreflight {0}", facade.getRequest().getURI()); if (!facade.getRequest().getMethod().equalsIgnoreCase("OPTIONS")) { diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java index 24ac81445e..28746e5387 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/RefreshableKeycloakSecurityContext.java @@ -26,7 +26,7 @@ public class RefreshableKeycloakSecurityContext extends KeycloakSecurityContext } public RefreshableKeycloakSecurityContext(KeycloakDeployment deployment, AdapterTokenStore tokenStore, String tokenString, AccessToken token, String idTokenString, IDToken idToken, String refreshToken) { - super(tokenString, token, idTokenString, idToken); + super(tokenString, token, idTokenString, idToken, deployment.getRealm()); this.deployment = deployment; this.tokenStore = tokenStore; this.refreshToken = refreshToken; @@ -67,6 +67,7 @@ public class RefreshableKeycloakSecurityContext extends KeycloakSecurityContext public void setCurrentRequestInfo(KeycloakDeployment deployment, AdapterTokenStore tokenStore) { this.deployment = deployment; this.tokenStore = tokenStore; + this.realm = deployment.getRealm(); } /** @@ -83,6 +84,11 @@ public class RefreshableKeycloakSecurityContext extends KeycloakSecurityContext if (this.deployment == null || refreshToken == null) return false; // Might be serialized in HttpSession? + if (!this.realm.equals(this.deployment.getRealm())) { + // this should not happen, but let's check it anyway + return false; + } + if (log.isTraceEnabled()) { log.trace("Doing refresh"); } diff --git a/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaCookieTokenStore.java b/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaCookieTokenStore.java index 26fd307826..ddf06aee3a 100644 --- a/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaCookieTokenStore.java +++ b/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaCookieTokenStore.java @@ -96,6 +96,12 @@ public class CatalinaCookieTokenStore implements AdapterTokenStore { } RefreshableKeycloakSecurityContext session = principal.getKeycloakSecurityContext(); + + if (!session.getRealm().equals(deployment.getRealm())) { + log.debug("Account from cookie is from a different realm than for the request."); + return null; + } + if (session.isActive() && !session.getDeployment().isAlwaysRefreshToken()) return principal; boolean success = session.refreshExpiredToken(false); if (success && session.isActive()) return principal; diff --git a/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaSessionTokenStore.java b/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaSessionTokenStore.java index 6fe8c5999e..b5361ce94b 100644 --- a/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaSessionTokenStore.java +++ b/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/CatalinaSessionTokenStore.java @@ -37,8 +37,15 @@ public class CatalinaSessionTokenStore implements AdapterTokenStore { if (request.getSessionInternal(false) == null || request.getSessionInternal().getPrincipal() == null) return; RefreshableKeycloakSecurityContext session = (RefreshableKeycloakSecurityContext) request.getSessionInternal().getNote(KeycloakSecurityContext.class.getName()); if (session == null) return; + + if (!deployment.getRealm().equals(session.getRealm())) { + log.debug("Account from cookie is from a different realm than for the request."); + return; + } + // just in case session got serialized if (session.getDeployment() == null) session.setCurrentRequestInfo(deployment, this); + if (session.isActive() && !session.getDeployment().isAlwaysRefreshToken()) return; // FYI: A refresh requires same scope, so same roles will be set. Otherwise, refresh will fail and token will diff --git a/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/KeycloakAuthenticatorValve.java b/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/KeycloakAuthenticatorValve.java index 9d25e92267..843b039488 100755 --- a/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/KeycloakAuthenticatorValve.java +++ b/integration/as7-eap6/adapter/src/main/java/org/keycloak/adapters/as7/KeycloakAuthenticatorValve.java @@ -6,27 +6,23 @@ import org.apache.catalina.LifecycleEvent; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleListener; import org.apache.catalina.Manager; -import org.apache.catalina.Session; import org.apache.catalina.authenticator.FormAuthenticator; import org.apache.catalina.connector.Request; import org.apache.catalina.connector.Response; import org.apache.catalina.core.StandardContext; import org.apache.catalina.deploy.LoginConfig; import org.jboss.logging.Logger; -import org.keycloak.KeycloakPrincipal; import org.keycloak.KeycloakSecurityContext; import org.keycloak.adapters.AdapterConstants; import org.keycloak.adapters.AdapterDeploymentContext; import org.keycloak.adapters.AdapterTokenStore; import org.keycloak.adapters.AuthChallenge; import org.keycloak.adapters.AuthOutcome; -import org.keycloak.adapters.CookieTokenStore; import org.keycloak.adapters.HttpFacade; import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.adapters.KeycloakDeploymentBuilder; import org.keycloak.adapters.NodesRegistrationManagement; import org.keycloak.adapters.PreAuthActionsHandler; -import org.keycloak.adapters.RefreshableKeycloakSecurityContext; import org.keycloak.enums.TokenStore; import javax.servlet.ServletContext; @@ -37,6 +33,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import org.keycloak.adapters.KeycloakConfigResolver; /** * Web deployment whose security is managed by a remote OAuth Skeleton Key authentication server @@ -117,16 +114,41 @@ public class KeycloakAuthenticatorValve extends FormAuthenticator implements Lif } + @SuppressWarnings("UseSpecificCatch") protected void init() { - InputStream configInputStream = getConfigInputStream(context); - KeycloakDeployment kd = null; - if (configInputStream == null) { - log.warn("No adapter configuration. Keycloak is unconfigured and will deny all requests."); - kd = new KeycloakDeployment(); + // Possible scenarios: + // 1) The deployment has a keycloak.config.resolver specified and it exists: + // Outcome: adapter uses the resolver + // 2) The deployment has a keycloak.config.resolver and isn't valid (doesn't exists, isn't a resolver, ...) : + // Outcome: adapter is left unconfigured + // 3) The deployment doesn't have a keycloak.config.resolver , but has a keycloak.json (or equivalent) + // Outcome: adapter uses it + // 4) The deployment doesn't have a keycloak.config.resolver nor keycloak.json (or equivalent) + // Outcome: adapter is left unconfigured + + String configResolverClass = (String) context.getServletContext().getAttribute("keycloak.config.resolver"); + if (configResolverClass != null) { + try { + KeycloakConfigResolver configResolver = (KeycloakConfigResolver) context.getLoader().getClassLoader().loadClass(configResolverClass).newInstance(); + deploymentContext = new AdapterDeploymentContext(configResolver); + log.info("Using " + configResolverClass + " to resolve Keycloak configuration on a per-request basis."); + } catch (Exception ex) { + log.warn("The specified resolver " + configResolverClass + " could NOT be loaded. Keycloak is unconfigured and will deny all requests. Reason: " + ex.getMessage()); + deploymentContext = new AdapterDeploymentContext(new KeycloakDeployment()); + } } else { - kd = KeycloakDeploymentBuilder.build(configInputStream); + InputStream configInputStream = getConfigInputStream(context); + KeycloakDeployment kd; + if (configInputStream == null) { + log.warn("No adapter configuration. Keycloak is unconfigured and will deny all requests."); + kd = new KeycloakDeployment(); + } else { + kd = KeycloakDeploymentBuilder.build(configInputStream); + } + deploymentContext = new AdapterDeploymentContext(kd); + log.debug("Keycloak is using a per-deployment configuration."); } - deploymentContext = new AdapterDeploymentContext(kd); + context.getServletContext().setAttribute(AdapterDeploymentContext.class.getName(), deploymentContext); AuthenticatedActionsValve actions = new AuthenticatedActionsValve(deploymentContext, getNext(), getContainer(), getController()); setNext(actions); diff --git a/integration/jaxrs-oauth-client/src/main/java/org/keycloak/jaxrs/JaxrsBearerTokenFilter.java b/integration/jaxrs-oauth-client/src/main/java/org/keycloak/jaxrs/JaxrsBearerTokenFilter.java index 5d700c1a8b..08a0a331c2 100755 --- a/integration/jaxrs-oauth-client/src/main/java/org/keycloak/jaxrs/JaxrsBearerTokenFilter.java +++ b/integration/jaxrs-oauth-client/src/main/java/org/keycloak/jaxrs/JaxrsBearerTokenFilter.java @@ -71,7 +71,7 @@ public class JaxrsBearerTokenFilter implements ContainerRequestFilter { try { AccessToken token = RSATokenVerifier.verifyToken(tokenString, realmPublicKey, realm); - KeycloakSecurityContext skSession = new KeycloakSecurityContext(tokenString, token, null, null); + KeycloakSecurityContext skSession = new KeycloakSecurityContext(tokenString, token, null, null, realm); ResteasyProviderFactory.pushContext(KeycloakSecurityContext.class, skSession); final KeycloakPrincipal principal = new KeycloakPrincipal(token.getSubject(), skSession); diff --git a/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaCookieTokenStore.java b/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaCookieTokenStore.java index dec12b917d..1678dd9895 100644 --- a/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaCookieTokenStore.java +++ b/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaCookieTokenStore.java @@ -94,6 +94,12 @@ public class CatalinaCookieTokenStore implements AdapterTokenStore { } RefreshableKeycloakSecurityContext session = principal.getKeycloakSecurityContext(); + + if (!session.getRealm().equals(deployment.getRealm())) { + log.fine("Account from cookie is from a different realm than for the request."); + return null; + } + if (session.isActive() && !session.getDeployment().isAlwaysRefreshToken()) return principal; boolean success = session.refreshExpiredToken(false); if (success && session.isActive()) return principal; diff --git a/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaSessionTokenStore.java b/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaSessionTokenStore.java index 81a765bb1c..6cc3ce699f 100644 --- a/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaSessionTokenStore.java +++ b/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/CatalinaSessionTokenStore.java @@ -35,8 +35,15 @@ public class CatalinaSessionTokenStore implements AdapterTokenStore { if (request.getSessionInternal(false) == null || request.getSessionInternal().getPrincipal() == null) return; RefreshableKeycloakSecurityContext session = (RefreshableKeycloakSecurityContext) request.getSessionInternal().getNote(KeycloakSecurityContext.class.getName()); if (session == null) return; + + if (!deployment.getRealm().equals(session.getRealm())) { + log.fine("Account from cookie is from a different realm than for the request."); + return; + } + // just in case session got serialized if (session.getDeployment() == null) session.setCurrentRequestInfo(deployment, this); + if (session.isActive() && !session.getDeployment().isAlwaysRefreshToken()) return; // FYI: A refresh requires same scope, so same roles will be set. Otherwise, refresh will fail and token will diff --git a/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/KeycloakAuthenticatorValve.java b/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/KeycloakAuthenticatorValve.java index 48b3c4e0f8..984f4d93b0 100755 --- a/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/KeycloakAuthenticatorValve.java +++ b/integration/tomcat7/adapter/src/main/java/org/keycloak/adapters/tomcat7/KeycloakAuthenticatorValve.java @@ -6,7 +6,6 @@ import org.apache.catalina.LifecycleEvent; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleListener; import org.apache.catalina.Manager; -import org.apache.catalina.Session; import org.apache.catalina.authenticator.FormAuthenticator; import org.apache.catalina.connector.Request; import org.apache.catalina.connector.Response; @@ -23,8 +22,6 @@ import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.adapters.KeycloakDeploymentBuilder; import org.keycloak.adapters.NodesRegistrationManagement; import org.keycloak.adapters.PreAuthActionsHandler; -import org.keycloak.adapters.RefreshableKeycloakSecurityContext; -import org.keycloak.adapters.ServerRequest; import org.keycloak.enums.TokenStore; import javax.servlet.ServletContext; @@ -35,7 +32,9 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.util.logging.Level; import java.util.logging.Logger; +import org.keycloak.adapters.KeycloakConfigResolver; /** * Web deployment whose security is managed by a remote OAuth Skeleton Key authentication server @@ -91,16 +90,42 @@ public class KeycloakAuthenticatorValve extends FormAuthenticator implements Lif cache = false; } + @SuppressWarnings("UseSpecificCatch") + @Override public void initInternal() { - InputStream configInputStream = getConfigInputStream(context); - KeycloakDeployment kd = null; - if (configInputStream == null) { - log.warning("No adapter configuration. Keycloak is unconfigured and will deny all requests."); - kd = new KeycloakDeployment(); + // Possible scenarios: + // 1) The deployment has a keycloak.config.resolver specified and it exists: + // Outcome: adapter uses the resolver + // 2) The deployment has a keycloak.config.resolver and isn't valid (doesn't exists, isn't a resolver, ...) : + // Outcome: adapter is left unconfigured + // 3) The deployment doesn't have a keycloak.config.resolver , but has a keycloak.json (or equivalent) + // Outcome: adapter uses it + // 4) The deployment doesn't have a keycloak.config.resolver nor keycloak.json (or equivalent) + // Outcome: adapter is left unconfigured + + String configResolverClass = (String) context.getServletContext().getAttribute("keycloak.config.resolver"); + if (configResolverClass != null) { + try { + KeycloakConfigResolver configResolver = (KeycloakConfigResolver) context.getLoader().getClassLoader().loadClass(configResolverClass).newInstance(); + deploymentContext = new AdapterDeploymentContext(configResolver); + log.log(Level.INFO, "Using {0} to resolve Keycloak configuration on a per-request basis.", configResolverClass); + } catch (Exception ex) { + log.log(Level.FINE, "The specified resolver {0} could NOT be loaded. Keycloak is unconfigured and will deny all requests. Reason: {1}", new Object[]{configResolverClass, ex.getMessage()}); + deploymentContext = new AdapterDeploymentContext(new KeycloakDeployment()); + } } else { - kd = KeycloakDeploymentBuilder.build(configInputStream); + InputStream configInputStream = getConfigInputStream(context); + KeycloakDeployment kd; + if (configInputStream == null) { + log.fine("No adapter configuration. Keycloak is unconfigured and will deny all requests."); + kd = new KeycloakDeployment(); + } else { + kd = KeycloakDeploymentBuilder.build(configInputStream); + } + deploymentContext = new AdapterDeploymentContext(kd); + log.fine("Keycloak is using a per-deployment configuration."); } - deploymentContext = new AdapterDeploymentContext(kd); + context.getServletContext().setAttribute(AdapterDeploymentContext.class.getName(), deploymentContext); AuthenticatedActionsValve actions = new AuthenticatedActionsValve(deploymentContext, getNext(), getContainer()); setNext(actions); diff --git a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/KeycloakServletExtension.java b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/KeycloakServletExtension.java index 5c7a16b4bd..948402195e 100755 --- a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/KeycloakServletExtension.java +++ b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/KeycloakServletExtension.java @@ -44,6 +44,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.InputStream; import java.util.Map; +import org.keycloak.adapters.KeycloakConfigResolver; /** * @author Bill Burke @@ -94,22 +95,49 @@ public class KeycloakServletExtension implements ServletExtension { @Override + @SuppressWarnings("UseSpecificCatch") public void handleDeployment(DeploymentInfo deploymentInfo, ServletContext servletContext) { if (!isAuthenticationMechanismPresent(deploymentInfo, "KEYCLOAK")) { log.debug("auth-method is not keycloak!"); return; } log.debug("KeycloakServletException initialization"); - InputStream is = getConfigInputStream(servletContext); - final KeycloakDeployment deployment; - if (is == null) { - log.warn("No adapter configuration. Keycloak is unconfigured and will deny all requests."); - deployment = new KeycloakDeployment(); - } else { - deployment = KeycloakDeploymentBuilder.build(is); + // Possible scenarios: + // 1) The deployment has a keycloak.config.resolver specified and it exists: + // Outcome: adapter uses the resolver + // 2) The deployment has a keycloak.config.resolver and isn't valid (doesn't exists, isn't a resolver, ...) : + // Outcome: adapter is left unconfigured + // 3) The deployment doesn't have a keycloak.config.resolver , but has a keycloak.json (or equivalent) + // Outcome: adapter uses it + // 4) The deployment doesn't have a keycloak.config.resolver nor keycloak.json (or equivalent) + // Outcome: adapter is left unconfigured + + KeycloakConfigResolver configResolver; + String configResolverClass = servletContext.getInitParameter("keycloak.config.resolver"); + AdapterDeploymentContext deploymentContext; + if (configResolverClass != null) { + try { + configResolver = (KeycloakConfigResolver) deploymentInfo.getClassLoader().loadClass(configResolverClass).newInstance(); + deploymentContext = new AdapterDeploymentContext(configResolver); + log.info("Using " + configResolverClass + " to resolve Keycloak configuration on a per-request basis."); + } catch (Exception ex) { + log.warn("The specified resolver " + configResolverClass + " could NOT be loaded. Keycloak is unconfigured and will deny all requests. Reason: " + ex.getMessage()); + deploymentContext = new AdapterDeploymentContext(new KeycloakDeployment()); + } + } else { + InputStream is = getConfigInputStream(servletContext); + final KeycloakDeployment deployment; + if (is == null) { + log.warn("No adapter configuration. Keycloak is unconfigured and will deny all requests."); + deployment = new KeycloakDeployment(); + } else { + deployment = KeycloakDeploymentBuilder.build(is); + } + deploymentContext = new AdapterDeploymentContext(deployment); + log.debug("Keycloak is using a per-deployment configuration."); } - AdapterDeploymentContext deploymentContext = new AdapterDeploymentContext(deployment); + servletContext.setAttribute(AdapterDeploymentContext.class.getName(), deploymentContext); UndertowUserSessionManagement userSessionManagement = new UndertowUserSessionManagement(); final NodesRegistrationManagement nodesRegistrationManagement = new NodesRegistrationManagement(); diff --git a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/ServletSessionTokenStore.java b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/ServletSessionTokenStore.java index fe0c6c9a98..3dccf8c1dd 100644 --- a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/ServletSessionTokenStore.java +++ b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/ServletSessionTokenStore.java @@ -53,6 +53,12 @@ public class ServletSessionTokenStore implements AdapterTokenStore { log.debug("Account was not in session, returning null"); return false; } + + if (!deployment.getRealm().equals(account.getKeycloakSecurityContext().getRealm())) { + log.debug("Account in session belongs to a different realm than for this request."); + return false; + } + account.setCurrentRequestInfo(deployment, this); if (account.checkActive()) { log.debug("Cached account found"); diff --git a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowCookieTokenStore.java b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowCookieTokenStore.java index 50859190fb..295c586022 100644 --- a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowCookieTokenStore.java +++ b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowCookieTokenStore.java @@ -45,6 +45,11 @@ public class UndertowCookieTokenStore implements AdapterTokenStore { } KeycloakUndertowAccount account = new KeycloakUndertowAccount(principal); + if (!deployment.getRealm().equals(account.getKeycloakSecurityContext().getRealm())) { + log.debug("Account in session belongs to a different realm than for this request."); + return false; + } + if (account.checkActive()) { log.debug("Cached account found"); securityContext.authenticationComplete(account, "KEYCLOAK", false); diff --git a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowSessionTokenStore.java b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowSessionTokenStore.java index 92848847ec..37325a8cdb 100644 --- a/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowSessionTokenStore.java +++ b/integration/undertow/src/main/java/org/keycloak/adapters/undertow/UndertowSessionTokenStore.java @@ -50,6 +50,12 @@ public class UndertowSessionTokenStore implements AdapterTokenStore { log.debug("Account was not in session, returning null"); return false; } + + if (!deployment.getRealm().equals(account.getKeycloakSecurityContext().getRealm())) { + log.debug("Account in session belongs to a different realm than for this request."); + return false; + } + account.setCurrentRequestInfo(deployment, this); if (account.checkActive()) { log.debug("Cached account found"); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenancyTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenancyTest.java new file mode 100644 index 0000000000..c6df988c07 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenancyTest.java @@ -0,0 +1,145 @@ +/* + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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; + +import javax.ws.rs.core.UriBuilder; +import org.junit.Assert; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OpenIDConnectService; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.managers.RealmManager; +import org.keycloak.testsuite.pages.LoginPage; +import org.keycloak.testsuite.rule.AbstractKeycloakRule; +import org.keycloak.testsuite.rule.WebResource; +import org.keycloak.testsuite.rule.WebRule; +import org.keycloak.testutils.KeycloakServer; +import org.openqa.selenium.WebDriver; + +/** + * + * @author Juraci Paixão Kröhling + */ +public class MultiTenancyTest { + @Rule + public WebRule webRule = new WebRule(this); + + @WebResource + protected LoginPage loginPage; + + @WebResource + protected WebDriver driver; + + @ClassRule + public static AbstractKeycloakRule keycloakRule = new AbstractKeycloakRule() { + @Override + protected void configure(KeycloakSession session, RealmManager manager, RealmModel adminRealm) { + RealmRepresentation tenant1 = KeycloakServer.loadJson(getClass().getResourceAsStream("/adapter-test/tenant1-realm.json"), RealmRepresentation.class); + manager.importRealm(tenant1); + + RealmRepresentation tenant2 = KeycloakServer.loadJson(getClass().getResourceAsStream("/adapter-test/tenant2-realm.json"), RealmRepresentation.class); + manager.importRealm(tenant2); + + deployApplication("multi-tenant", "/multi-tenant", MultiTenantServlet.class, null, "user", true, MultiTenantResolver.class); + } + }; + + /** + * Simplest scenario: one user, one realm. The user is not logged in at + * any other realm + * @throws Exception + */ + @Test + public void testTenantsLoggingOut() throws Exception { + doTenantRequests("tenant1", true); + doTenantRequests("tenant2", true); + } + + /** + * This tests the adapter's ability to deal with multiple sessions + * from the same user, one for each realm. It should not mixup and return + * a session from tenant1 to tenant2 + * @throws Exception + */ + @Test + public void testTenantsWithoutLoggingOut() throws Exception { + doTenantRequests("tenant1", true); + doTenantRequests("tenant2", true); + + doTenantRequests("tenant1", false); + doTenantRequests("tenant2", true); + } + + /** + * This test simulates an user that is not logged in yet, and tris to login + * into tenant1 using an account from tenant2. + * On this scenario, the user should be shown the login page again. + * + * @throws Exception + */ + @Test + public void testUnauthorizedAccessNotLoggedIn() throws Exception { + String keycloakServerBaseUrl = "http://localhost:8081/auth"; + + driver.navigate().to("http://localhost:8081/multi-tenant?realm=tenant1"); + Assert.assertTrue(driver.getCurrentUrl().startsWith(keycloakServerBaseUrl)); + + loginPage.login("user-tenant2", "user-tenant2"); + Assert.assertTrue(driver.getCurrentUrl().startsWith(keycloakServerBaseUrl)); + } + + /** + * This test simulates an user which is already logged in into tenant1 + * and tries to access a resource on tenant2. + * On this scenario, the user should be shown the login page again. + * + * @throws Exception + */ + @Test + public void testUnauthorizedAccessLoggedIn() throws Exception { + String keycloakServerBaseUrl = "http://localhost:8081/auth"; + doTenantRequests("tenant1", false); + + driver.navigate().to("http://localhost:8081/multi-tenant?realm=tenant2"); + Assert.assertTrue(driver.getCurrentUrl().startsWith(keycloakServerBaseUrl)); + } + + private void doTenantRequests(String tenant, boolean logout) { + String tenantLoginUrl = OpenIDConnectService.loginPageUrl(UriBuilder.fromUri("http://localhost:8081/auth")).build(tenant).toString(); + + driver.navigate().to("http://localhost:8081/multi-tenant?realm="+tenant); + System.out.println("Current url: " + driver.getCurrentUrl()); + + Assert.assertTrue(driver.getCurrentUrl().startsWith(tenantLoginUrl)); + loginPage.login("bburke@redhat.com", "password"); + System.out.println("Current url: " + driver.getCurrentUrl()); + + Assert.assertEquals("http://localhost:8081/multi-tenant?realm="+tenant, driver.getCurrentUrl()); + String pageSource = driver.getPageSource(); + System.out.println(pageSource); + + Assert.assertTrue(pageSource.contains("Username: bburke@redhat.com")); + Assert.assertTrue(pageSource.contains("Realm: "+tenant)); + + if (logout) { + driver.manage().deleteAllCookies(); + } + } +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantResolver.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantResolver.java new file mode 100644 index 0000000000..1acf95da7d --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantResolver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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; + +import java.io.InputStream; +import org.keycloak.adapters.HttpFacade; +import org.keycloak.adapters.KeycloakConfigResolver; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; + +/** + * + * @author Juraci Paixão Kröhling + */ +public class MultiTenantResolver implements KeycloakConfigResolver { + + @Override + public KeycloakDeployment resolve(HttpFacade.Request request) { + String realm = request.getQueryParamValue("realm"); + InputStream is = getClass().getResourceAsStream("/adapter-test/"+realm+"-keycloak.json"); + + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(is); + return deployment; + } + +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java new file mode 100644 index 0000000000..5e04cf29e3 --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java @@ -0,0 +1,47 @@ +/* + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @author tags. All rights reserved. + * + * 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; + +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.keycloak.KeycloakSecurityContext; + +/** + * + * @author Juraci Paixão Kröhling + */ +public class MultiTenantServlet extends HttpServlet { + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + resp.setContentType("text/html"); + PrintWriter pw = resp.getWriter(); + KeycloakSecurityContext context = (KeycloakSecurityContext)req.getAttribute(KeycloakSecurityContext.class.getName()); + + pw.print("Username: "); + pw.println(context.getIdToken().getPreferredUsername()); + + pw.print("
Realm: "); + pw.println(context.getRealm()); + + pw.flush(); + } +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/AbstractKeycloakRule.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/AbstractKeycloakRule.java index 06e793f92a..0808dcb50a 100755 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/AbstractKeycloakRule.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/rule/AbstractKeycloakRule.java @@ -24,6 +24,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.Socket; +import org.keycloak.adapters.KeycloakConfigResolver; /** * @author Bill Burke @@ -133,9 +134,17 @@ public abstract class AbstractKeycloakRule extends ExternalResource { } public void deployApplication(String name, String contextPath, Class servletClass, String adapterConfigPath, String role, boolean isConstrained) { + deployApplication(name, contextPath, servletClass, adapterConfigPath, role, isConstrained, null); + } + + public void deployApplication(String name, String contextPath, Class servletClass, String adapterConfigPath, String role, boolean isConstrained, Class keycloakConfigResolver) { String constraintUrl = "/*"; DeploymentInfo di = createDeploymentInfo(name, contextPath, servletClass); - di.addInitParameter("keycloak.config.file", adapterConfigPath); + if (null == keycloakConfigResolver) { + di.addInitParameter("keycloak.config.file", adapterConfigPath); + } else { + di.addInitParameter("keycloak.config.resolver", keycloakConfigResolver.getCanonicalName()); + } if (isConstrained) { SecurityConstraint constraint = new SecurityConstraint(); WebResourceCollection collection = new WebResourceCollection(); diff --git a/testsuite/integration/src/test/resources/adapter-test/tenant1-keycloak.json b/testsuite/integration/src/test/resources/adapter-test/tenant1-keycloak.json new file mode 100644 index 0000000000..80bff8eaac --- /dev/null +++ b/testsuite/integration/src/test/resources/adapter-test/tenant1-keycloak.json @@ -0,0 +1,10 @@ +{ + "realm" : "tenant1", + "resource" : "multi-tenant", + "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url" : "http://localhost:8081/auth", + "ssl-required" : "external", + "credentials" : { + "secret": "password" + } +} \ No newline at end of file diff --git a/testsuite/integration/src/test/resources/adapter-test/tenant1-realm.json b/testsuite/integration/src/test/resources/adapter-test/tenant1-realm.json new file mode 100644 index 0000000000..783776f24e --- /dev/null +++ b/testsuite/integration/src/test/resources/adapter-test/tenant1-realm.json @@ -0,0 +1,75 @@ +{ + "id": "tenant1", + "realm": "tenant1", + "enabled": true, + "accessTokenLifespan": 3000, + "accessCodeLifespan": 10, + "accessCodeLifespanUserAction": 6000, + "sslRequired": "external", + "registrationAllowed": false, + "social": false, + "passwordCredentialGrantAllowed": true, + "updateProfileOnInitialSocialLogin": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": [ "password" ], + "users" : [ + { + "username" : "bburke@redhat.com", + "enabled": true, + "email" : "bburke@redhat.com", + "firstName": "Bill", + "lastName": "Burke", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": [ "user" ], + "applicationRoles": { + "multi-tenant": [ "user" ] + } + }, + { + "username" : "user-tenant1", + "enabled": true, + "email" : "user-tenant1@redhat.com", + "firstName": "Bill", + "lastName": "Burke", + "credentials" : [ + { "type" : "password", + "value" : "user-tenant1" } + ], + "realmRoles": [ "user" ], + "applicationRoles": { + "multi-tenant": [ "user" ] + } + } + ], + "roles" : { + "realm" : [ + { + "name": "user", + "description": "User privileges" + } + ] + }, + "scopeMappings": [ + { + "client": "multi-tenant", + "roles": ["user"] + } + + ], + "applications": [ + { + "name": "multi-tenant", + "enabled": true, + "adminUrl": "http://localhost:8081/multi-tenant", + "baseUrl": "http://localhost:8081/multi-tenant", + "redirectUris": [ + "http://localhost:8081/multi-tenant/*" + ], + "secret": "password" + } + ] +} diff --git a/testsuite/integration/src/test/resources/adapter-test/tenant2-keycloak.json b/testsuite/integration/src/test/resources/adapter-test/tenant2-keycloak.json new file mode 100644 index 0000000000..deb538d2b8 --- /dev/null +++ b/testsuite/integration/src/test/resources/adapter-test/tenant2-keycloak.json @@ -0,0 +1,10 @@ +{ + "realm" : "tenant2", + "resource" : "multi-tenant", + "realm-public-key" : "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "auth-server-url" : "http://localhost:8081/auth", + "ssl-required" : "external", + "credentials" : { + "secret": "password" + } +} \ No newline at end of file diff --git a/testsuite/integration/src/test/resources/adapter-test/tenant2-realm.json b/testsuite/integration/src/test/resources/adapter-test/tenant2-realm.json new file mode 100644 index 0000000000..1c17f118d5 --- /dev/null +++ b/testsuite/integration/src/test/resources/adapter-test/tenant2-realm.json @@ -0,0 +1,75 @@ +{ + "id": "tenant2", + "realm": "tenant2", + "enabled": true, + "accessTokenLifespan": 3000, + "accessCodeLifespan": 10, + "accessCodeLifespanUserAction": 6000, + "sslRequired": "external", + "registrationAllowed": false, + "social": false, + "passwordCredentialGrantAllowed": true, + "updateProfileOnInitialSocialLogin": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": [ "password" ], + "users" : [ + { + "username" : "bburke@redhat.com", + "enabled": true, + "email" : "bburke@redhat.com", + "firstName": "Bill", + "lastName": "Burke", + "credentials" : [ + { "type" : "password", + "value" : "password" } + ], + "realmRoles": [ "user" ], + "applicationRoles": { + "multi-tenant": [ "user" ] + } + }, + { + "username" : "user-tenant2", + "enabled": true, + "email" : "user-tenant2@redhat.com", + "firstName": "Bill", + "lastName": "Burke", + "credentials" : [ + { "type" : "password", + "value" : "user-tenant2" } + ], + "realmRoles": [ "user" ], + "applicationRoles": { + "multi-tenant": [ "user" ] + } + } + ], + "roles" : { + "realm" : [ + { + "name": "user", + "description": "User privileges" + } + ] + }, + "scopeMappings": [ + { + "client": "multi-tenant", + "roles": ["user"] + } + + ], + "applications": [ + { + "name": "multi-tenant", + "enabled": true, + "adminUrl": "http://localhost:8081/multi-tenant", + "baseUrl": "http://localhost:8081/multi-tenant", + "redirectUris": [ + "http://localhost:8081/multi-tenant/*" + ], + "secret": "password" + } + ] +}