KEYCLOAK-779

Adapter multi-tenancy support
This commit is contained in:
Juraci Paixão Kröhling 2014-10-21 10:07:06 +02:00 committed by Stian Thorgersen
parent a56cef47f2
commit 8e764e642f
42 changed files with 1140 additions and 42 deletions

5
.gitignore vendored
View file

@ -9,6 +9,11 @@
.settings
.classpath
# NetBeans #
############
nb-configuration.xml
# Compiled source #
###################
*.com

View file

@ -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

View file

@ -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();
}

View file

@ -42,6 +42,14 @@
<exclude name="**/subsystem-config.xml"/>
</fileset>
</copy>
<copy todir="target/examples/multi-tenant" overwrite="true">
<fileset dir="../../examples/multi-tenant">
<exclude name="**/target/**"/>
<exclude name="**/*.iml"/>
<exclude name="**/*.unconfigured"/>
<exclude name="**/subsystem-config.xml"/>
</fileset>
</copy>
<copy todir="target/examples/themes" overwrite="true">
<fileset dir="../../examples/themes">
<exclude name="**/target/**"/>

View file

@ -36,6 +36,7 @@
<!ENTITY SecurityVulnerabilities SYSTEM "modules/security-vulnerabilities.xml">
<!ENTITY Clustering SYSTEM "modules/clustering.xml">
<!ENTITY ApplicationClustering SYSTEM "modules/application-clustering.xml">
<!ENTITY MultiTenancy SYSTEM "modules/multi-tenancy.xml">
]>
<book>
@ -88,6 +89,7 @@ This one is short
&JavascriptAdapter;
&InstalledApplications;
&Logout;
&MultiTenancy;
</chapter>
<chapter>

View file

@ -0,0 +1,56 @@
<section id="multi_tenancy">
<title>Multi Tenancy</title>
<para>
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 <literal>keycloak.json</literal> files.
For this case, there are two possible solutions:
<itemizedlist>
<listitem>
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.
</listitem>
<listitem>
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
(<literal>client1.acme.com</literal>, <literal>client2.acme.com</literal>) or path (<literal>/app/client1/</literal>,
<literal>/app/client2/</literal>) or even via a special HTTP Header.
</listitem>
</itemizedlist>
This chapter of the reference guide focus on this second scenario.
</para>
<para>
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:
<itemizedlist>
<listitem>
Add a context parameter to the <literal>web.xml</literal>, named <literal>keycloak.config.resolver</literal>.
The value of this property should be the fully qualified name of the a class extending
<literal>org.keycloak.adapters.KeycloakConfigResolver</literal>.
</listitem>
<listitem>
A concrete implementation of <literal>org.keycloak.adapters.KeycloakConfigResolver</literal>. Keycloak will call the
<literal>resolve(org.keycloak.adapters.HttpFacade.Request)</literal> method and expects a complete
<literal>org.keycloak.adapters.KeycloakDeployment</literal> in response. Note that Keycloak will call this for every request,
so, take the usual performance precautions.
</listitem>
</itemizedlist>
</para>
<para>
An implementation of this feature can be found on the examples.
</para>
</section>

View file

@ -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`

View file

@ -6,7 +6,7 @@
<version>1.1.0-Alpha1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<name>Examples</name>
<name>Keycloak Examples - CORS</name>
<description/>
<modelVersion>4.0.0</modelVersion>

View file

@ -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``

View file

@ -0,0 +1,65 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>keycloak-parent</artifactId>
<groupId>org.keycloak</groupId>
<version>1.1.0-Alpha1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<name>Keycloak Examples - Multi Tenant</name>
<artifactId>multitenant</artifactId>
<packaging>war</packaging>
<description>
Keycloak Multi Tenants Example
</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.wildfly.bom</groupId>
<artifactId>jboss-javaee-7.0-with-all</artifactId>
<version>8.0.0.Final</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-core</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jboss.spec.javax.servlet</groupId>
<artifactId>jboss-servlet-api_3.1_spec</artifactId>
<scope>provided</scope>
</dependency>
<!-- Contains KeycloakDeployment and KeycloakConfigResolver -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-core</artifactId>
</dependency>
<!-- Contains KeycloakPrincipal -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}</finalName>
</build>
</project>

View file

@ -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 <juraci at kroehling.de>
*/
@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("<br/>User: ");
writer.write(principal.getKeycloakSecurityContext().getIdToken().getPreferredUsername());
writer.write(String.format("<br/><a href=\"/multitenant/%s/logout\">Logout</a>", realm));
}
}

View file

@ -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 <juraci at kroehling.de>
*/
public class PathBasedKeycloakConfigResolver implements KeycloakConfigResolver {
private final Map<String, KeycloakDeployment> cache = new ConcurrentHashMap<String, KeycloakDeployment>();
@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;
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright (C) 2014 Juraci Paixão Kröhling <juraci at kroehling.de>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<jboss:jboss
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:jboss="http://www.jboss.com/xml/ns/javaee"
xmlns:s="urn:security:1.1"
version="3.1" impl-version="2.0">
<assembly-descriptor>
<s:security>
<ejb-name>*</ejb-name>
<s:security-domain>keycloak</s:security-domain>
</s:security>
</assembly-descriptor>
</jboss:jboss>

View file

@ -0,0 +1,29 @@
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<display-name>Multi Tenant Example</display-name>
<context-param>
<param-name>keycloak.config.resolver</param-name>
<param-value>org.keycloak.example.multitenant.control.PathBasedKeycloakConfigResolver</param-value>
</context-param>
<security-constraint>
<web-resource-collection>
<web-resource-name>REST endpoints</web-resource-name>
<url-pattern>/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>*</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>KEYCLOAK</auth-method>
<realm-name>not-important</realm-name>
</login-config>
<security-role>
<role-name>user</role-name>
</security-role>
</web-app>

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -29,5 +29,6 @@
<module>demo-template</module>
<module>providers</module>
<module>js-console</module>
<module>multi-tenant</module>
</modules>
</project>

View file

@ -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);
}
}

View file

@ -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 <juraci at kroehling.de>
*/
public interface KeycloakConfigResolver {
public KeycloakDeployment resolve(Request facade);
}

View file

@ -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")) {

View file

@ -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");
}

View file

@ -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;

View file

@ -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

View file

@ -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,9 +114,31 @@ public class KeycloakAuthenticatorValve extends FormAuthenticator implements Lif
}
@SuppressWarnings("UseSpecificCatch")
protected void init() {
// 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 {
InputStream configInputStream = getConfigInputStream(context);
KeycloakDeployment kd = null;
KeycloakDeployment kd;
if (configInputStream == null) {
log.warn("No adapter configuration. Keycloak is unconfigured and will deny all requests.");
kd = new KeycloakDeployment();
@ -127,6 +146,9 @@ public class KeycloakAuthenticatorValve extends FormAuthenticator implements Lif
kd = KeycloakDeploymentBuilder.build(configInputStream);
}
deploymentContext = new AdapterDeploymentContext(kd);
log.debug("Keycloak is using a per-deployment configuration.");
}
context.getServletContext().setAttribute(AdapterDeploymentContext.class.getName(), deploymentContext);
AuthenticatedActionsValve actions = new AuthenticatedActionsValve(deploymentContext, getNext(), getContainer(), getController());
setNext(actions);

View file

@ -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<KeycloakSecurityContext> principal = new KeycloakPrincipal<KeycloakSecurityContext>(token.getSubject(), skSession);

View file

@ -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;

View file

@ -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

View file

@ -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() {
// 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 {
InputStream configInputStream = getConfigInputStream(context);
KeycloakDeployment kd = null;
KeycloakDeployment kd;
if (configInputStream == null) {
log.warning("No adapter configuration. Keycloak is unconfigured and will deny all requests.");
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.");
}
context.getServletContext().setAttribute(AdapterDeploymentContext.class.getName(), deploymentContext);
AuthenticatedActionsValve actions = new AuthenticatedActionsValve(deploymentContext, getNext(), getContainer());
setNext(actions);

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -94,12 +95,37 @@ 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");
// 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) {
@ -107,9 +133,11 @@ public class KeycloakServletExtension implements ServletExtension {
deployment = new KeycloakDeployment();
} else {
deployment = KeycloakDeploymentBuilder.build(is);
}
AdapterDeploymentContext deploymentContext = new AdapterDeploymentContext(deployment);
deploymentContext = new AdapterDeploymentContext(deployment);
log.debug("Keycloak is using a per-deployment configuration.");
}
servletContext.setAttribute(AdapterDeploymentContext.class.getName(), deploymentContext);
UndertowUserSessionManagement userSessionManagement = new UndertowUserSessionManagement();
final NodesRegistrationManagement nodesRegistrationManagement = new NodesRegistrationManagement();

View file

@ -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");

View file

@ -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);

View file

@ -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");

View file

@ -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 <juraci at kroehling.de>
*/
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();
}
}
}

View file

@ -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 <juraci at kroehling.de>
*/
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;
}
}

View file

@ -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 <juraci at kroehling.de>
*/
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("<br/>Realm: ");
pw.println(context.getRealm());
pw.flush();
}
}

View file

@ -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 <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -133,9 +134,17 @@ public abstract class AbstractKeycloakRule extends ExternalResource {
}
public void deployApplication(String name, String contextPath, Class<? extends Servlet> servletClass, String adapterConfigPath, String role, boolean isConstrained) {
deployApplication(name, contextPath, servletClass, adapterConfigPath, role, isConstrained, null);
}
public void deployApplication(String name, String contextPath, Class<? extends Servlet> servletClass, String adapterConfigPath, String role, boolean isConstrained, Class<? extends KeycloakConfigResolver> keycloakConfigResolver) {
String constraintUrl = "/*";
DeploymentInfo di = createDeploymentInfo(name, contextPath, servletClass);
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();

View file

@ -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"
}
}

View file

@ -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"
}
]
}

View file

@ -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"
}
}

View file

@ -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"
}
]
}