keycloak-uncached

KEYCLOAK-4501

2/27/2017 8:46:00 PM

Changes

Details

diff --git a/common/src/main/java/org/keycloak/common/util/HttpPostRedirect.java b/common/src/main/java/org/keycloak/common/util/HttpPostRedirect.java
new file mode 100644
index 0000000..0a421b8
--- /dev/null
+++ b/common/src/main/java/org/keycloak/common/util/HttpPostRedirect.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.common.util;
+
+import java.util.Map;
+
+
+/**
+ * Helper class to do a browser redirect via a POST.
+ *
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class HttpPostRedirect {
+
+    /**
+     * Generate an HTML page that does a browser redirect via a POST.  The HTML document uses Javascript to automatically
+     * submit a FORM post when loaded.
+     *
+     * This is similar to what the SAML Post Binding does.
+     *
+     * Here's an example
+     *
+     * <pre>
+     * {@code
+     * <HTML>
+     *   <HEAD>
+     *       <TITLE>title</TITLE>
+     *   </HEAD>
+     *   <BODY Onload="document.forms[0].submit()">
+     *       <FORM METHOD="POST" ACTION="actionUrl">
+     *           <INPUT TYPE="HIDDEN" NAME="param" VALUE="value"/>
+     *           <NOSCRIPT>
+     *               <P>JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue.</P>
+     *               <INPUT TYPE="SUBMIT" VALUE="CONTINUE"/>
+     *           </NOSCRIPT>
+     *       </FORM>
+     *   </BODY>
+     * </HTML>
+     * }
+     * </pre>
+
+     *
+     * @param title may be null.  Just the title of the HTML document
+     * @param actionUrl URL to redirect to
+     * @param params must be encoded so that they can be placed in an HTML form hidden INPUT field value
+     * @return
+     */
+    public String buildHtml(String title, String actionUrl, Map<String, String> params) {
+        StringBuilder builder = new StringBuilder();
+        builder.append("<HTML>")
+                .append("<HEAD>");
+        if (title != null) {
+            builder.append("<TITLE>SAML HTTP Post Binding</TITLE>");
+        }
+        builder.append("</HEAD>")
+                .append("<BODY Onload=\"document.forms[0].submit()\">")
+
+                .append("<FORM METHOD=\"POST\" ACTION=\"").append(actionUrl).append("\">");
+        for (Map.Entry<String, String> param : params.entrySet()) {
+            builder.append("<INPUT TYPE=\"HIDDEN\" NAME=\"").append(param.getKey()).append("\"").append(" VALUE=\"").append(param.getValue()).append("\"/>");
+        }
+
+
+        builder.append("<NOSCRIPT>")
+                .append("<P>JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue.</P>")
+                .append("<INPUT TYPE=\"SUBMIT\" VALUE=\"CONTINUE\" />")
+                .append("</NOSCRIPT>")
+
+                .append("</FORM></BODY></HTML>");
+
+        return builder.toString();
+    }
+
+}
diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java
index ea2b887..e17743e 100755
--- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java
+++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java
@@ -75,4 +75,7 @@ public interface Errors {
     String PASSWORD_CONFIRM_ERROR = "password_confirm_error";
     String PASSWORD_MISSING = "password_missing";
     String PASSWORD_REJECTED = "password_rejected";
+    String NOT_LOGGED_IN = "not_logged_in";
+    String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";
+    String ILLEGAL_ORIGIN = "illegal_origin";
 }
diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java
index f97b713..f77137f 100755
--- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java
+++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java
@@ -113,7 +113,10 @@ public enum EventType {
     CLIENT_UPDATE(true),
     CLIENT_UPDATE_ERROR(true),
     CLIENT_DELETE(true),
-    CLIENT_DELETE_ERROR(true);
+    CLIENT_DELETE_ERROR(true),
+
+    CLIENT_INITIATED_ACCOUNT_LINKING(true),
+    CLIENT_INITIATED_ACCOUNT_LINKING_ERROR(true);
 
     private boolean saveByDefault;
 
diff --git a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java
index 170925b..eb0dd69 100755
--- a/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java
+++ b/services/src/main/java/org/keycloak/broker/oidc/AbstractOAuth2IdentityProvider.java
@@ -216,8 +216,10 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
             if (error != null) {
                 //logger.error("Failed " + getConfig().getAlias() + " broker login: " + error);
                 if (error.equals(ACCESS_DENIED)) {
+                    logger.error(ACCESS_DENIED + " for broker login " + getConfig().getProviderId());
                     return callback.cancelled(state);
                 } else {
+                    logger.error(error + " for broker login " + getConfig().getProviderId());
                     return callback.error(state, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
                 }
             }
diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
index 9646ecc..cf7d73c 100755
--- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
@@ -699,7 +699,7 @@ public class AuthenticationManager {
     }
 
 
-    protected static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
+    public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
                                                     boolean isCookie, String tokenString, HttpHeaders headers) {
         try {
             TokenVerifier verifier = TokenVerifier.create(tokenString).realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())).checkActive(checkActive).checkTokenType(checkTokenType);
diff --git a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
index 162de45..3b4c017 100755
--- a/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
+++ b/services/src/main/java/org/keycloak/services/resources/IdentityBrokerService.java
@@ -17,6 +17,7 @@
 package org.keycloak.services.resources;
 
 import org.jboss.logging.Logger;
+import org.jboss.resteasy.annotations.cache.NoCache;
 import org.jboss.resteasy.spi.HttpRequest;
 import org.jboss.resteasy.spi.ResteasyProviderFactory;
 
@@ -34,8 +35,10 @@ import org.keycloak.broker.provider.IdentityProviderMapper;
 import org.keycloak.broker.saml.SAMLEndpoint;
 import org.keycloak.broker.social.SocialIdentityProvider;
 import org.keycloak.common.ClientConnection;
+import org.keycloak.common.util.Base64Url;
 import org.keycloak.common.util.ObjectUtil;
 import org.keycloak.common.util.Time;
+import org.keycloak.common.util.UriUtils;
 import org.keycloak.events.Details;
 import org.keycloak.events.Errors;
 import org.keycloak.events.EventBuilder;
@@ -55,17 +58,20 @@ import org.keycloak.models.RoleModel;
 import org.keycloak.models.UserModel;
 import org.keycloak.models.UserSessionModel;
 import org.keycloak.models.utils.FormMessage;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
 import org.keycloak.protocol.oidc.TokenManager;
+import org.keycloak.protocol.oidc.utils.RedirectUtils;
 import org.keycloak.protocol.saml.SamlProtocol;
 import org.keycloak.protocol.saml.SamlService;
 import org.keycloak.provider.ProviderFactory;
 import org.keycloak.representations.AccessToken;
-import org.keycloak.saml.common.constants.GeneralConstants;
 import org.keycloak.services.ErrorPage;
+import org.keycloak.services.ErrorPageException;
 import org.keycloak.services.ErrorResponse;
 import org.keycloak.services.ServicesLogger;
 import org.keycloak.services.Urls;
 import org.keycloak.services.managers.AppAuthManager;
+import org.keycloak.services.managers.AuthenticationManager;
 import org.keycloak.services.managers.AuthenticationManager.AuthResult;
 import org.keycloak.services.managers.BruteForceProtector;
 import org.keycloak.services.managers.ClientSessionCode;
@@ -88,6 +94,9 @@ import javax.ws.rs.core.UriBuilder;
 import javax.ws.rs.core.UriInfo;
 import java.io.IOException;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -95,6 +104,7 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.UUID;
 
 import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT;
 import static org.keycloak.models.ClientSessionModel.Action.AUTHENTICATE;
@@ -140,6 +150,162 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
         this.event = new EventBuilder(realmModel, session, clientConnection).event(EventType.IDENTITY_PROVIDER_LOGIN);
     }
 
+    private void checkRealm() {
+        if (!realmModel.isEnabled()) {
+            event.error(Errors.REALM_DISABLED);
+            throw new ErrorPageException(session, Messages.REALM_NOT_ENABLED);
+        }
+    }
+
+    private ClientModel checkClient(String clientId) {
+        if (clientId == null) {
+            event.error(Errors.INVALID_REQUEST);
+            throw new ErrorPageException(session, Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
+        }
+
+        event.client(clientId);
+
+        ClientModel client = realmModel.getClientByClientId(clientId);
+        if (client == null) {
+            event.error(Errors.CLIENT_NOT_FOUND);
+            throw new ErrorPageException(session, Messages.INVALID_REQUEST);
+        }
+
+        if (!client.isEnabled()) {
+            event.error(Errors.CLIENT_DISABLED);
+            throw new ErrorPageException(session, Messages.INVALID_REQUEST);
+        }
+        return client;
+
+    }
+
+    /**
+     * Closes off CORS preflight requests for account linking
+     *
+     * @param providerId
+     * @return
+     */
+    @OPTIONS
+    @Path("/{provider_id}/link")
+    public Response clientIntiatedAccountLinkingPreflight(@PathParam("provider_id") String providerId) {
+        return Response.status(403).build(); // don't allow preflight
+    }
+
+
+    @GET
+    @NoCache
+    @Path("/{provider_id}/link")
+    public Response clientInitiatedAccountLinking(@PathParam("provider_id") String providerId,
+                                                  @QueryParam("redirect_uri") String redirectUri,
+                                                  @QueryParam("client_id") String clientId,
+                                                  @QueryParam("nonce") String nonce,
+                                                  @QueryParam("hash") String hash
+    ) {
+        this.event.event(EventType.CLIENT_INITIATED_ACCOUNT_LINKING);
+        checkRealm();
+        ClientModel client = checkClient(clientId);
+        AuthenticationManager authenticationManager = new AuthenticationManager();
+        redirectUri = RedirectUtils.verifyRedirectUri(uriInfo, redirectUri, realmModel, client);
+        if (redirectUri == null) {
+            event.error(Errors.INVALID_REDIRECT_URI);
+            throw new ErrorPageException(session, Messages.INVALID_REQUEST);
+        }
+
+        if (nonce == null || hash == null) {
+            event.error(Errors.INVALID_REDIRECT_URI);
+            throw new ErrorPageException(session, Messages.INVALID_REQUEST);
+
+        }
+
+        // only allow origins from client.  Not sure we need this as I don't believe cookies can be
+        // sent if CORS preflight requests can't execute.
+        String origin = headers.getRequestHeaders().getFirst("Origin");
+        if (origin != null) {
+            String redirectOrigin = UriUtils.getOrigin(redirectUri);
+            if (!redirectOrigin.equals(origin)) {
+                event.error(Errors.ILLEGAL_ORIGIN);
+                throw new ErrorPageException(session, Messages.INVALID_REQUEST);
+
+            }
+        }
+
+        AuthResult cookieResult = authenticationManager.authenticateIdentityCookie(session, realmModel, true);
+        if (cookieResult == null) {
+            event.error(Errors.NOT_LOGGED_IN);
+            UriBuilder builder = UriBuilder.fromUri(redirectUri)
+                    .queryParam("error", Errors.NOT_LOGGED_IN)
+                    .queryParam("nonce", nonce);
+
+            return Response.status(302).location(builder.build()).build();
+        }
+
+
+
+        ClientSessionModel clientSession = null;
+        for (ClientSessionModel cs : cookieResult.getSession().getClientSessions()) {
+            if (cs.getClient().getClientId().equals(clientId)) {
+                byte[] decoded = Base64Url.decode(hash);
+                MessageDigest md = null;
+                try {
+                    md = MessageDigest.getInstance("SHA-256");
+                } catch (NoSuchAlgorithmException e) {
+                    throw new ErrorPageException(session, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST);
+                }
+                String input = nonce + cookieResult.getSession().getId() + cs.getId() + providerId;
+                byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
+                if (MessageDigest.isEqual(decoded, check)) {
+                    clientSession = cs;
+                }
+                break;
+            }
+        }
+        if (clientSession == null) {
+            event.error(Errors.INVALID_TOKEN);
+            throw new ErrorPageException(session, Messages.INVALID_REQUEST);
+        }
+
+        IdentityProviderModel identityProviderModel = realmModel.getIdentityProviderByAlias(providerId);
+        if (identityProviderModel == null) {
+            event.error(Errors.UNKNOWN_IDENTITY_PROVIDER);
+            UriBuilder builder = UriBuilder.fromUri(redirectUri)
+                    .queryParam("error", Errors.UNKNOWN_IDENTITY_PROVIDER)
+                    .queryParam("nonce", nonce);
+            return Response.status(302).location(builder.build()).build();
+
+        }
+
+
+
+        ClientSessionCode clientSessionCode = new ClientSessionCode(session, realmModel, clientSession);
+        clientSessionCode.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
+        clientSessionCode.getCode();
+        clientSession.setRedirectUri(redirectUri);
+        clientSession.setNote(OIDCLoginProtocol.STATE_PARAM, UUID.randomUUID().toString());
+
+        event.success();
+
+
+        try {
+            IdentityProvider identityProvider = getIdentityProvider(session, realmModel, providerId);
+            Response response = identityProvider.performLogin(createAuthenticationRequest(providerId, clientSessionCode));
+
+            if (response != null) {
+                if (isDebugEnabled()) {
+                    logger.debugf("Identity provider [%s] is going to send a request [%s].", identityProvider, response);
+                }
+                return response;
+            }
+        } catch (IdentityBrokerException e) {
+            return redirectToErrorPage(Messages.COULD_NOT_SEND_AUTHENTICATION_REQUEST, e, providerId);
+        } catch (Exception e) {
+            return redirectToErrorPage(Messages.UNEXPECTED_ERROR_HANDLING_REQUEST, e, providerId);
+        }
+
+        return redirectToErrorPage(Messages.COULD_NOT_PROCEED_WITH_AUTHENTICATION_REQUEST);
+
+    }
+
+
     @POST
     @Path("/{provider_id}/login")
     public Response performPostLogin(@PathParam("provider_id") String providerId, @QueryParam("code") String code) {
@@ -147,6 +313,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
     }
 
     @GET
+    @NoCache
     @Path("/{provider_id}/login")
     public Response performLogin(@PathParam("provider_id") String providerId, @QueryParam("code") String code) {
         this.event.detail(Details.IDENTITY_PROVIDER, providerId);
@@ -198,11 +365,18 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
     }
 
     @GET
+    @NoCache
     @Path("{provider_id}/token")
     public Response retrieveToken(@PathParam("provider_id") String providerId) {
         return getToken(providerId, false);
     }
 
+    private boolean canReadBrokerToken(AccessToken token) {
+        Map<String, AccessToken.Access> resourceAccess = token.getResourceAccess();
+        AccessToken.Access brokerRoles = resourceAccess == null ? null : resourceAccess.get(Constants.BROKER_SERVICE_CLIENT_ID);
+        return brokerRoles != null && brokerRoles.isUserInRole(Constants.READ_TOKEN_ROLE);
+    }
+
     private Response getToken(String providerId, boolean forceRetrieval) {
         this.event.event(EventType.IDENTITY_PROVIDER_RETRIEVE_TOKEN);
 
@@ -226,9 +400,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
                     return corsResponse(forbidden("Realm has not migrated to support the broker token exchange service"), clientModel);
 
                 }
-                Map<String, AccessToken.Access> resourceAccess = token.getResourceAccess();
-                AccessToken.Access brokerRoles = resourceAccess == null ? null : resourceAccess.get(Constants.BROKER_SERVICE_CLIENT_ID);
-                if (brokerRoles == null || !brokerRoles.isUserInRole(Constants.READ_TOKEN_ROLE)) {
+                if (!canReadBrokerToken(token)) {
                     return corsResponse(forbidden("Client [" + clientModel.getClientId() + "] not authorized to retrieve tokens from identity provider [" + providerId + "]."), clientModel);
 
                 }
@@ -366,6 +538,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
 
     // Callback from LoginActionsService after first login with broker was done and Keycloak account is successfully linked/created
     @GET
+    @NoCache
     @Path("/after-first-broker-login")
     public Response afterFirstBrokerLogin(@QueryParam("code") String code) {
         ParsedCodeContext parsedCode = parseClientSessionCode(code);
@@ -487,6 +660,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
 
     // Callback from LoginActionsService after postBrokerLogin flow is finished
     @GET
+    @NoCache
     @Path("/after-post-broker-login")
     public Response afterPostBrokerLoginFlow(@QueryParam("code") String code) {
         ParsedCodeContext parsedCode = parseClientSessionCode(code);
@@ -613,7 +787,19 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
         this.event.event(EventType.FEDERATED_IDENTITY_LINK);
 
         if (federatedUser != null) {
-            return redirectToAccountErrorPage(clientSession, Messages.IDENTITY_PROVIDER_ALREADY_LINKED, context.getIdpConfig().getAlias());
+            // refresh the token
+            if (context.getIdpConfig().isStoreToken()) {
+                federatedIdentityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel);
+                if (!ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
+
+                    this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
+
+                    if (isDebugEnabled()) {
+                        logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias());
+                    }
+                }
+            }
+            return Response.status(302).location(UriBuilder.fromUri(clientSession.getRedirectUri()).build()).build();
         }
 
         UserModel authenticatedUser = clientSession.getUserSession().getUser();
@@ -645,15 +831,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
         FederatedIdentityModel federatedIdentityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel);
 
         // Skip DB write if tokens are null or equal
-        if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
-            federatedIdentityModel.setToken(context.getToken());
-
-            this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
-
-            if (isDebugEnabled()) {
-                logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias());
-            }
-        }
+        updateToken(context, federatedUser, federatedIdentityModel);
         context.getIdp().updateBrokeredUser(session, realmModel, federatedUser, context);
         Set<IdentityProviderMapperModel> mappers = realmModel.getIdentityProviderMappersByAlias(context.getIdpConfig().getAlias());
         if (mappers != null) {
@@ -666,6 +844,18 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
 
     }
 
+    private void updateToken(BrokeredIdentityContext context, UserModel federatedUser, FederatedIdentityModel federatedIdentityModel) {
+        if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
+            federatedIdentityModel.setToken(context.getToken());
+
+            this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
+
+            if (isDebugEnabled()) {
+                logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias());
+            }
+        }
+    }
+
     private ParsedCodeContext parseClientSessionCode(String code) {
         ClientSessionCode clientCode = ClientSessionCode.parse(code, this.session, this.realmModel);
 
diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/AdapterTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/AdapterTest.java
index 6e3da8d..4a2ce96 100755
--- a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/AdapterTest.java
+++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/AdapterTest.java
@@ -104,21 +104,6 @@ public class AdapterTest {
         Thread.sleep(1000000000);
     }
 
-    public static class MySuper {
-
-    }
-
-    public static class Base extends MySuper {
-        public Class superClass() {
-            return super.getClass();
-        }
-    }
-
-    @Test
-    public void testBase() {
-        System.out.println(new Base().superClass().getName());
-    }
-
     @Test
     public void testLoginSSOAndLogout() throws Exception {
         testStrategy.testLoginSSOMax();
diff --git a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ClientInitiatedAccountLinkServlet.java b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ClientInitiatedAccountLinkServlet.java
new file mode 100644
index 0000000..4309566
--- /dev/null
+++ b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ClientInitiatedAccountLinkServlet.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.testsuite.adapter.servlet;
+
+import org.keycloak.KeycloakSecurityContext;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.common.util.KeycloakUriBuilder;
+import org.keycloak.representations.AccessToken;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.UUID;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class ClientInitiatedAccountLinkServlet extends HttpServlet {
+    @Override
+    protected void doGet(HttpServletRequest request, HttpServletResponse resp) throws ServletException, IOException {
+        if (request.getRequestURI().endsWith("/link") && request.getParameter("response") == null) {
+            String provider = request.getParameter("provider");
+            String realm = request.getParameter("realm");
+            KeycloakSecurityContext session = (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
+            AccessToken token = session.getToken();
+            String clientSessionId = token.getClientSession();
+            String nonce = UUID.randomUUID().toString();
+            MessageDigest md = null;
+            try {
+                md = MessageDigest.getInstance("SHA-256");
+            } catch (NoSuchAlgorithmException e) {
+                throw new RuntimeException(e);
+            }
+            String input = nonce + token.getSessionState() + clientSessionId + provider;
+            byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
+            String hash = Base64Url.encode(check);
+            request.getSession().setAttribute("hash", hash);
+            String redirectUri = KeycloakUriBuilder.fromUri(request.getRequestURL().toString())
+                    .replaceQuery(null)
+                    .queryParam("response", "true").build().toString();
+            String accountLinkUrl = KeycloakUriBuilder.fromUri(ServletTestUtils.getAuthServerUrlBase())
+                    .path("/auth/realms/{realm}/broker/{provider}/link")
+                    .queryParam("nonce", nonce)
+                    .queryParam("hash", hash)
+                    .queryParam("client_id", token.getIssuedFor())
+                    .queryParam("redirect_uri", redirectUri).build(realm, provider).toString();
+            resp.setStatus(302);
+            resp.setHeader("Location", accountLinkUrl);
+        } else if (request.getRequestURI().endsWith("/link") && request.getParameter("response") != null) {
+            String hash = request.getSession().getAttribute("hash").toString();
+            String hashParam = request.getParameter("hash");
+            resp.setStatus(200);
+            resp.setContentType("text/html");
+            PrintWriter pw = resp.getWriter();
+            pw.printf("<html><head><title>%s</title></head><body>", "Client Linking");
+            String error = request.getParameter("error");
+            if (error != null) {
+                pw.println("Link error: " + error);
+            } else {
+                pw.println("Account Linked");
+            }
+            pw.print("</body></html>");
+            pw.flush();
+        } else {
+            resp.setStatus(200);
+            resp.setContentType("text/html");
+            PrintWriter pw = resp.getWriter();
+            pw.printf("<html><head><title>%s</title></head><body>", "Client Linking");
+            pw.println("Unknown request: " + request.getRequestURL().toString());
+            pw.print("</body></html>");
+            pw.flush();
+
+        }
+
+    }
+}
diff --git a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ServletTestUtils.java b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ServletTestUtils.java
index 8097c4f..8a064a6 100644
--- a/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ServletTestUtils.java
+++ b/testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ServletTestUtils.java
@@ -51,6 +51,6 @@ public class ServletTestUtils {
             return System.getProperty("auth.server.ssl.base.url", "https://localhost:8543");
         }
 
-        return System.getProperty("auth.server.base.url");
+        return System.getProperty("auth.server.base.url", "http://localhost:8180");
     }
 }
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java
index 94a8fb6..11d8fb2 100755
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java
@@ -144,7 +144,12 @@ public class LoginPage extends AbstractPage {
 
 
     public boolean isCurrent() {
-        return driver.getTitle().equals("Log in to test") || driver.getTitle().equals("Anmeldung bei test");
+        String realm = "test";
+        return isCurrent(realm);
+    }
+
+    public boolean isCurrent(String realm) {
+        return driver.getTitle().equals("Log in to " + realm) || driver.getTitle().equals("Anmeldung bei " + realm);
     }
 
     public void clickRegister() {
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/ClientInitiatedAccountLinkTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/ClientInitiatedAccountLinkTest.java
new file mode 100644
index 0000000..6c08851
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/ClientInitiatedAccountLinkTest.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.testsuite.broker;
+
+import org.apache.http.client.utils.URIBuilder;
+import org.jboss.arquillian.container.test.api.Deployment;
+import org.jboss.arquillian.container.test.api.OperateOnDeployment;
+import org.jboss.arquillian.graphene.page.Page;
+import org.jboss.arquillian.test.api.ArquillianResource;
+import org.jboss.shrinkwrap.api.spec.WebArchive;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.keycloak.admin.client.resource.RealmResource;
+import org.keycloak.common.util.Base64Url;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.FederatedIdentityRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.RoleRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.keycloak.testsuite.AbstractKeycloakTest;
+import org.keycloak.testsuite.adapter.page.AppServerContextRoot;
+import org.keycloak.testsuite.adapter.servlet.ClientInitiatedAccountLinkServlet;
+import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
+import org.keycloak.testsuite.arquillian.annotation.AppServerContainer;
+import org.keycloak.testsuite.federation.PassThroughFederatedUserStorageProvider;
+import org.keycloak.testsuite.page.AbstractPageWithInjectedUrl;
+import org.keycloak.testsuite.pages.AccountFederatedIdentityPage;
+import org.keycloak.testsuite.pages.LoginPage;
+import org.keycloak.testsuite.pages.UpdateAccountInformationPage;
+import org.keycloak.testsuite.util.AdapterServletDeployment;
+
+import javax.ws.rs.core.UriBuilder;
+import java.net.URL;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.Assert.assertTrue;
+import static org.keycloak.testsuite.admin.ApiUtil.createUserAndResetPasswordWithAdminClient;
+
+/**
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+@AppServerContainer("auth-server-undertow")
+public class ClientInitiatedAccountLinkTest extends AbstractKeycloakTest {
+    public static final String CHILD_IDP = "child";
+    public static final String PARENT_IDP = "parent-idp";
+    public static final String PARENT_USERNAME = "parent";
+
+    @Page
+    protected UpdateAccountInformationPage profilePage;
+
+    @Page
+    protected LoginPage loginPage;
+
+    @Page
+    protected AppServerContextRoot appServerContextRootPage;
+
+    public boolean isRelative() {
+        return testContext.isRelativeAdapterTest();
+    }
+
+    public static class ClientApp extends AbstractPageWithInjectedUrl {
+
+        public static final String DEPLOYMENT_NAME = "client-linking";
+
+        @ArquillianResource
+        @OperateOnDeployment(DEPLOYMENT_NAME)
+        private URL url;
+
+        @Override
+        public URL getInjectedUrl() {
+            return url;
+        }
+
+    }
+
+    @Page
+    protected ClientApp appPage;
+
+
+    @Override
+    public void addTestRealms(List<RealmRepresentation> testRealms) {
+        RealmRepresentation realm = new RealmRepresentation();
+        realm.setRealm(CHILD_IDP);
+        realm.setEnabled(true);
+        ClientRepresentation servlet = new ClientRepresentation();
+        servlet.setClientId("client-linking");
+        servlet.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
+        String uri = "/client-linking";
+        if (!isRelative()) {
+            uri = appServerContextRootPage.toString() + uri;
+        }
+        servlet.setAdminUrl(uri);
+        servlet.setBaseUrl(uri);
+        servlet.setRedirectUris(new LinkedList<>());
+        servlet.getRedirectUris().add(uri + "/*");
+        servlet.setSecret("password");
+        servlet.setFullScopeAllowed(true);
+        realm.setClients(new LinkedList<>());
+        realm.getClients().add(servlet);
+        testRealms.add(realm);
+
+
+        realm = new RealmRepresentation();
+        realm.setRealm(PARENT_IDP);
+        realm.setEnabled(true);
+
+        testRealms.add(realm);
+
+    }
+
+    @Deployment(name = "client-linking")
+    public static WebArchive customerPortal() {
+        return AdapterServletDeployment.oidcDeployment("client-linking", "/account-link-test", ClientInitiatedAccountLinkServlet.class);
+    }
+
+
+    @Before
+    public void addIdpUser() {
+        RealmResource realm = adminClient.realms().realm(PARENT_IDP);
+        UserRepresentation user = new UserRepresentation();
+        user.setUsername(PARENT_USERNAME);
+        user.setEnabled(true);
+        String userId = createUserAndResetPasswordWithAdminClient(realm, user, "password");
+
+    }
+
+    private String childUserId = null;
+
+    @Before
+    public void addChildUser() {
+        RealmResource realm = adminClient.realms().realm(CHILD_IDP);
+        UserRepresentation user = new UserRepresentation();
+        user.setUsername("child");
+        user.setEnabled(true);
+        childUserId = createUserAndResetPasswordWithAdminClient(realm, user, "password");
+
+        // have to add a role as stupid undertow auth manager doesn't like "*"
+        realm.roles().create(new RoleRepresentation("user", null, false));
+        RoleRepresentation role = realm.roles().get("user").toRepresentation();
+        List<RoleRepresentation> roles = new LinkedList<>();
+        roles.add(role);
+        realm.users().get(childUserId).roles().realmLevel().add(roles);
+
+    }
+
+    @Before
+    public void createBroker() {
+        createParentChild();
+    }
+
+    public void createParentChild() {
+        BrokerTestTools.createKcOidcBroker(adminClient, CHILD_IDP, PARENT_IDP, suiteContext);
+    }
+
+    @Test
+    public void testErrorConditions() {
+        RealmResource realm = adminClient.realms().realm(CHILD_IDP);
+        List<FederatedIdentityRepresentation> links = realm.users().get(childUserId).getFederatedIdentity();
+        Assert.assertTrue(links.isEmpty());
+
+        UriBuilder redirectUri = UriBuilder.fromUri(appPage.getInjectedUrl().toString())
+                .path("link")
+                .queryParam("response", "true");
+
+        UriBuilder directLinking = UriBuilder.fromUri(AuthServerTestEnricher.getAuthServerContextRoot() + "/auth")
+                .path("realms/child/broker/{provider}/link")
+                .queryParam("client_id", "client-linking")
+                .queryParam("redirect_uri", redirectUri.build())
+                .queryParam("hash", Base64Url.encode("crap".getBytes()))
+                .queryParam("nonce", UUID.randomUUID().toString());
+
+        String linkUrl = directLinking
+                .build(PARENT_IDP).toString();
+        // test not logged in
+
+
+        driver.navigate().to(linkUrl);
+
+        Assert.assertTrue(driver.getCurrentUrl().contains("error=not_logged_in"));
+
+
+        // now log in
+
+        driver.navigate().to( appPage.getInjectedUrl() + "/hello");
+        loginPage.login("child", "password");
+        Assert.assertTrue(driver.getCurrentUrl().startsWith(appPage.getInjectedUrl() + "/hello"));
+
+        // now test CSRF with bad hash.
+
+        driver.navigate().to(linkUrl);
+
+        Assert.assertTrue(driver.getPageSource().contains("We're sorry..."));
+
+
+
+    }
+
+    @Test
+    public void testAccountLink() {
+        RealmResource realm = adminClient.realms().realm(CHILD_IDP);
+        List<FederatedIdentityRepresentation> links = realm.users().get(childUserId).getFederatedIdentity();
+        Assert.assertTrue(links.isEmpty());
+
+        UriBuilder linkBuilder = UriBuilder.fromUri(appPage.getInjectedUrl().toString())
+                .path("link");
+        String linkUrl = linkBuilder.clone()
+                .queryParam("realm", CHILD_IDP)
+                .queryParam("provider", PARENT_IDP).build().toString();
+        driver.navigate().to(linkUrl);
+        Assert.assertTrue(loginPage.isCurrent(CHILD_IDP));
+        loginPage.login("child", "password");
+        Assert.assertTrue(loginPage.isCurrent(PARENT_IDP));
+        loginPage.login(PARENT_USERNAME, "password");
+        System.out.println("After linking: " + driver.getCurrentUrl());
+        System.out.println(driver.getPageSource());
+        Assert.assertTrue(driver.getCurrentUrl().startsWith(linkBuilder.toTemplate()));
+        Assert.assertTrue(driver.getPageSource().contains("Account Linked"));
+
+        links = realm.users().get(childUserId).getFederatedIdentity();
+        Assert.assertFalse(links.isEmpty());
+    }
+
+
+
+
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdapterServletDeployment.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdapterServletDeployment.java
new file mode 100644
index 0000000..d8328c2
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdapterServletDeployment.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2016 Red Hat, Inc. and/or its affiliates
+ * and other contributors as indicated by the @author tags.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.keycloak.testsuite.util;
+
+import org.apache.commons.io.IOUtils;
+import org.jboss.shrinkwrap.api.Archive;
+import org.jboss.shrinkwrap.api.ShrinkWrap;
+import org.jboss.shrinkwrap.api.asset.StringAsset;
+import org.jboss.shrinkwrap.api.spec.WebArchive;
+
+import java.io.IOException;
+import java.net.URL;
+
+/**
+ * Expects a structure like adapter-test directory
+ *
+ * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
+ * @version $Revision: 1 $
+ */
+public class AdapterServletDeployment {
+
+    public static final String JBOSS_DEPLOYMENT_STRUCTURE_XML = "jboss-deployment-structure.xml";
+    public static final String TOMCAT_CONTEXT_XML = "context.xml";
+
+    // hardcoded for now
+    public static final URL tomcatContext = AdapterServletDeployment.class
+            .getResource("/adapter-test/" + TOMCAT_CONTEXT_XML);
+
+    public static WebArchive oidcDeployment(String name, String configRoot, Class... servletClasses) {
+        return oidcDeployment(name, configRoot, "keycloak.json");
+
+    }
+
+
+    public static WebArchive oidcDeployment(String name, String configRoot, String adapterConfigFilename, Class... servletClasses) {
+        String configPath = configRoot + "/" + name;
+        String webInfPath = configPath + "/WEB-INF/";
+
+        URL keycloakJSON = AdapterServletDeployment.class.getResource(webInfPath + adapterConfigFilename);
+        URL webXML = AdapterServletDeployment.class.getResource(webInfPath + "web.xml");
+
+        WebArchive deployment = ShrinkWrap.create(WebArchive.class, name + ".war")
+                .addClasses(servletClasses)
+                .addAsWebInfResource(webXML, "web.xml");
+
+        URL keystore = AdapterServletDeployment.class.getResource(webInfPath + "keystore.jks");
+        if (keystore != null) {
+            deployment.addAsWebInfResource(keystore, "classes/keystore.jks");
+        }
+
+        if (keycloakJSON != null) {
+            deployment.addAsWebInfResource(keycloakJSON, "keycloak.json");
+        }
+
+        URL jbossDeploymentStructure = AdapterServletDeployment.class.getResource(webInfPath + JBOSS_DEPLOYMENT_STRUCTURE_XML);
+        if (jbossDeploymentStructure == null) {
+            jbossDeploymentStructure = AdapterServletDeployment.class.getResource(configRoot + "/" + JBOSS_DEPLOYMENT_STRUCTURE_XML);
+        }
+        if (jbossDeploymentStructure != null) deployment.addAsWebInfResource(jbossDeploymentStructure, JBOSS_DEPLOYMENT_STRUCTURE_XML);
+
+        addContextXml(deployment, name);
+
+        return deployment;
+    }
+
+
+    public static void addContextXml(Archive archive, String contextPath) {
+        // hardcoded for now
+        try {
+            String contextXmlContent = IOUtils.toString(tomcatContext.openStream())
+                    .replace("%CONTEXT_PATH%", contextPath);
+            archive.add(new StringAsset(contextXmlContent), "/META-INF/context.xml");
+        } catch (IOException ex) {
+            throw new RuntimeException(ex);
+        }
+    }
+
+
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/childrealm.json b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/childrealm.json
new file mode 100644
index 0000000..57cc7e8
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/childrealm.json
@@ -0,0 +1,38 @@
+{
+    "id": "child",
+    "realm": "child",
+    "enabled": true,
+    "accessTokenLifespan": 600,
+    "accessCodeLifespan": 10,
+    "accessCodeLifespanUserAction": 6000,
+    "sslRequired": "external",
+    "registrationAllowed": 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" }
+            ]
+        }
+    ],
+    "clients": [
+        {
+            "clientId": "client-linking",
+            "enabled": true,
+            "adminUrl": "/client-linking",
+            "baseUrl": "/client-linking",
+            "redirectUris": [
+                "/client-linking/*"
+            ],
+            "secret": "password"
+        }
+    ]
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/META-INF/context.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/META-INF/context.xml
new file mode 100644
index 0000000..b4ddcce
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/META-INF/context.xml
@@ -0,0 +1,20 @@
+<!--
+  ~ Copyright 2016 Red Hat, Inc. and/or its affiliates
+  ~ and other contributors as indicated by the @author tags.
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~ http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<Context path="/customer-portal">
+    <Valve className="org.keycloak.adapters.tomcat.KeycloakAuthenticatorValve"/>
+</Context>
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/jetty-web.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/jetty-web.xml
new file mode 100644
index 0000000..8c59313
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/jetty-web.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0"?>
+<!--
+  ~ Copyright 2016 Red Hat, Inc. and/or its affiliates
+  ~ and other contributors as indicated by the @author tags.
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~ http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://www.eclipse.org/jetty/configure_9_0.dtd">
+<Configure class="org.eclipse.jetty.webapp.WebAppContext">
+    <Get name="securityHandler">
+        <Set name="authenticator">
+            <New class="org.keycloak.adapters.jetty.KeycloakJettyAuthenticator">
+                <!--
+                <Set name="adapterConfig">
+                    <New class="org.keycloak.representations.adapters.config.AdapterConfig">
+                        <Set name="realm">tomcat</Set>
+                        <Set name="resource">customer-portal</Set>
+                        <Set name="authServerUrl">http://localhost:8180/auth</Set>
+                        <Set name="sslRequired">external</Set>
+                        <Set name="credentials">
+                            <Map>
+                                <Entry>
+                                    <Item>secret</Item>
+                                    <Item>password</Item>
+                                </Entry>
+                            </Map>
+                        </Set>
+                        <Set name="realmKey">MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB</Set>
+                    </New>
+                </Set>
+                -->
+            </New>
+        </Set>
+    </Get>
+</Configure>
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/keycloak.json b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/keycloak.json
new file mode 100644
index 0000000..c447d58
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/keycloak.json
@@ -0,0 +1,9 @@
+{
+  "realm" : "child",
+  "resource" : "client-linking",
+  "auth-server-url" : "http://localhost:8180/auth",
+  "ssl-required" : "external",
+  "credentials" : {
+      "secret": "password"
+   }
+}
\ No newline at end of file
diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/web.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/web.xml
new file mode 100644
index 0000000..8142588
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/web.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Copyright 2016 Red Hat, Inc. and/or its affiliates
+  ~ and other contributors as indicated by the @author tags.
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~ http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<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">
+
+    <module-name>client-linking</module-name>
+
+    <servlet>
+        <servlet-name>Servlet</servlet-name>
+        <servlet-class>org.keycloak.testsuite.adapter.servlet.ClientInitiatedAccountLinkServlet</servlet-class>
+    </servlet>
+
+    <servlet-mapping>
+        <servlet-name>Servlet</servlet-name>
+        <url-pattern>/*</url-pattern>
+    </servlet-mapping>
+
+    <security-constraint>
+        <web-resource-collection>
+            <web-resource-name>Users</web-resource-name>
+            <url-pattern>/*</url-pattern>
+        </web-resource-collection>
+        <auth-constraint>
+            <role-name>user</role-name>
+        </auth-constraint>
+    </security-constraint>
+
+    <login-config>
+        <auth-method>KEYCLOAK</auth-method>
+        <realm-name>child</realm-name>
+    </login-config>
+
+    <security-role>
+        <role-name>user</role-name>
+    </security-role>
+</web-app>
diff --git a/testsuite/pom.xml b/testsuite/pom.xml
index ea6253f..1edd186 100755
--- a/testsuite/pom.xml
+++ b/testsuite/pom.xml
@@ -57,6 +57,7 @@
         <module>tomcat8</module>
         <module>jetty</module>
         <module>integration-arquillian</module>
+        <module>stress</module>
     </modules>
         
 </project>