keycloak-uncached
Changes
testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ClientInitiatedAccountLinkServlet.java 97(+97 -0)
testsuite/integration-arquillian/test-apps/servlets/src/main/java/org/keycloak/testsuite/adapter/servlet/ServletTestUtils.java 2(+1 -1)
testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java 7(+6 -1)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/broker/ClientInitiatedAccountLinkTest.java 245(+245 -0)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/AdapterServletDeployment.java 93(+93 -0)
testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/childrealm.json 38(+38 -0)
testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/META-INF/context.xml 20(+20 -0)
testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/jetty-web.xml 46(+46 -0)
testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/keycloak.json 9(+9 -0)
testsuite/integration-arquillian/tests/base/src/test/resources/account-link-test/client-linking/WEB-INF/web.xml 54(+54 -0)
testsuite/pom.xml 1(+1 -0)
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>
testsuite/pom.xml 1(+1 -0)
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>