diff --git a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java
index 74275bbd9ae..d5bb5fa020d 100755
--- a/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java
+++ b/services/src/main/java/org/keycloak/authentication/AuthenticationProcessor.java
@@ -39,6 +39,7 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.models.utils.FormMessage;
+import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.oidc.TokenManager;
@@ -822,6 +823,13 @@ public class AuthenticationProcessor {
return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_USER);
}
+ } else if (KeycloakModelUtils.isExceptionRetriable(failure)) {
+ // let calling code decide if whole action should be retried.
+ if (failure instanceof RuntimeException) {
+ throw (RuntimeException) failure;
+ } else {
+ throw new RuntimeException(failure);
+ }
} else {
ServicesLogger.LOGGER.failedAuthentication(failure);
event.error(Errors.INVALID_USER_CREDENTIALS);
diff --git a/services/src/main/java/org/keycloak/common/util/ResponseSessionTask.java b/services/src/main/java/org/keycloak/common/util/ResponseSessionTask.java
new file mode 100644
index 00000000000..2c697de33d9
--- /dev/null
+++ b/services/src/main/java/org/keycloak/common/util/ResponseSessionTask.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2022 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 javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.Response;
+
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.KeycloakSessionTaskWithResult;
+import org.keycloak.models.RealmModel;
+import org.keycloak.sessions.RootAuthenticationSessionModel;
+
+/**
+ * A {@link KeycloakSessionTaskWithResult} that is aimed to be used by endpoints that want to produce a {@link Response} in
+ * a retriable transaction. It takes care of the boilerplate code that is common to be seen in this scenario, allowing the
+ * endpoint to focus on the actual code that has to be executed in a retriable manner.
+ *
+ * More specifically, this task:
+ *
+ * pushes the task's session into the resteasy context, restoring the original value after the task is over. This allows
+ * for endpoints to create new instances of themselves and inject the resteasy properties correctly;
+ * sets up the task's session context, based on model values found in the original session's context;
+ * handles {@link WebApplicationException} when it is thrown by the task.
+ *
+ *
+ * @author Stefan Guilhen
+ */
+public abstract class ResponseSessionTask implements KeycloakSessionTaskWithResult {
+
+ private final KeycloakSession originalSession;
+
+ /**
+ * Constructs a new instance of this task.
+ *
+ * @param originalSession the original {@link KeycloakSession} that was active when the task was created.
+ */
+ public ResponseSessionTask(final KeycloakSession originalSession) {
+ this.originalSession = originalSession;
+ }
+
+ @Override
+ public Response run(final KeycloakSession session) {
+ // save the session that was originally in the resteasy context, so it can be restored once the task finishes.
+ KeycloakSession originalContextSession = Resteasy.getContextData(KeycloakSession.class);
+ try {
+ // set up the current session context based on the original session context.
+ this.setupSessionContext(session);
+ // push the current session into the resteasy context.
+ Resteasy.pushContext(KeycloakSession.class, session);
+ // run the actual task.
+ return runInternal(session);
+ } catch (WebApplicationException we) {
+ // If the exception is capable of producing a complete response, including a message entity, we return the response
+ // here so that the overall transaction is still committed. If the message entity is missing, we throw the exception
+ // so that KeycloakError handler is invoked later on to produce a valid response and the transaction is rolled back.
+ //
+ // Another reason to convert the web application exception into a response here is because some exception subtypes use
+ // the Keycloak session to construct the response. As a result, the conversion has to happen before the session is closed.
+ Response response = we.getResponse();
+ if (response.getEntity() != null) {
+ return response;
+ }
+ throw we;
+ } finally {
+ // restore original session in resteasy context.
+ Resteasy.pushContext(KeycloakSession.class, originalContextSession);
+ }
+ }
+
+ /**
+ * Sets up the context for the specified session. The original realm's context is used to determine what models
+ * need to be re-loaded using the current session.
+ *
+ * @param session the session whose context is to be prepared.
+ */
+ private void setupSessionContext(final KeycloakSession session) {
+ if (this.originalSession == null) return;
+ KeycloakContext context = this.originalSession.getContext();
+ // setup realm model if necessary.
+ RealmModel realmModel = null;
+ if (context.getRealm() != null) {
+ realmModel = session.realms().getRealm(context.getRealm().getId());
+ session.getContext().setRealm(realmModel);
+ }
+ // setup client model if necessary.
+ ClientModel clientModel = null;
+ if (context.getClient() != null) {
+ clientModel = session.clients().getClientById(realmModel, context.getClient().getId());
+ session.getContext().setClient(clientModel);
+ }
+ // setup auth session model if necessary.
+ if (context.getAuthenticationSession() != null) {
+ RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realmModel,
+ context.getAuthenticationSession().getParentSession().getId());
+ if (rootAuthSession != null) {
+ session.getContext().setAuthenticationSession(rootAuthSession.getAuthenticationSession(clientModel,
+ context.getAuthenticationSession().getTabId()));
+ }
+ }
+ }
+
+ /**
+ * Builds the response that is to be returned.
+ *
+ * @param session a reference the {@link KeycloakSession}.
+ * @return the constructed {@link Response}.
+ */
+ public abstract Response runInternal(final KeycloakSession session);
+}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
index 4797c3d2946..4e57538e3df 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
@@ -20,6 +20,7 @@ package org.keycloak.protocol.oidc.endpoints;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationProcessor;
+import org.keycloak.common.util.ResponseSessionTask;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@@ -30,6 +31,7 @@ import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
@@ -96,17 +98,22 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
event.event(EventType.LOGIN);
}
+ private AuthorizationEndpoint(final KeycloakSession session, final EventBuilder event, final Action action) {
+ this(session, event);
+ this.action = action;
+ }
+
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response buildPost() {
logger.trace("Processing @POST request");
- return process(httpRequest.getDecodedFormParameters());
+ return processInRetriableTransaction(httpRequest.getDecodedFormParameters());
}
@GET
public Response buildGet() {
logger.trace("Processing @GET request");
- return process(session.getContext().getUri().getQueryParameters());
+ return processInRetriableTransaction(session.getContext().getUri().getQueryParameters());
}
/**
@@ -117,6 +124,21 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
return new DeviceEndpoint(session, event);
}
+ /**
+ * Process the request in a retriable transaction.
+ */
+ private Response processInRetriableTransaction(final MultivaluedMap formParameters) {
+ return KeycloakModelUtils.runJobInRetriableTransaction(session.getKeycloakSessionFactory(), new ResponseSessionTask(session) {
+ @Override
+ public Response runInternal(KeycloakSession session) {
+ // create another instance of the endpoint to isolate each run.
+ AuthorizationEndpoint other = new AuthorizationEndpoint(session,
+ new EventBuilder(session.getContext().getRealm(), session, clientConnection), action);
+ // process the request in the created instance.
+ return other.process(formParameters); }
+ }, 10, 100);
+ }
+
private Response process(MultivaluedMap params) {
String clientId = AuthorizationEndpointRequestParserProcessor.getClientId(event, session, params);
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
index 61b412753bf..251dc72857b 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java
@@ -30,7 +30,7 @@ import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.KeycloakUriBuilder;
-import org.keycloak.common.util.Resteasy;
+import org.keycloak.common.util.ResponseSessionTask;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@@ -106,7 +106,6 @@ import javax.ws.rs.InternalServerErrorException;
import javax.ws.rs.OPTIONS;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
-import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedHashMap;
@@ -175,26 +174,16 @@ public class TokenEndpoint {
public Response processGrantRequest() {
// grant request needs to be run in a retriable transaction as concurrent execution of this action can lead to
// exceptions on DBs with SERIALIZABLE isolation level.
- Object result = KeycloakModelUtils.runJobInRetriableTransaction(this.session.getKeycloakSessionFactory(), kcSession -> {
- try {
- RealmModel realmModel = kcSession.realms().getRealm(realm.getId());
- kcSession.getContext().setRealm(realmModel);
- // create another instance of the endpoint that will be run within the new session.
- Resteasy.pushContext(KeycloakSession.class, kcSession);
- TokenEndpoint other = new TokenEndpoint(session, new TokenManager(), new EventBuilder(realmModel, kcSession, clientConnection));
+ return KeycloakModelUtils.runJobInRetriableTransaction(session.getKeycloakSessionFactory(), new ResponseSessionTask(session) {
+ @Override
+ public Response runInternal(KeycloakSession session) {
+ // create another instance of the endpoint to isolate each run.
+ TokenEndpoint other = new TokenEndpoint(session, new TokenManager(),
+ new EventBuilder(session.getContext().getRealm(), session, clientConnection));
+ // process the request in the created instance.
return other.processGrantRequestInternal();
- } catch (WebApplicationException we) {
- // WebApplicationException needs to be returned and treated (rethrown) by the calling code because the new transaction
- // still needs to be committed when this exception is thrown. It captures final business states that won't change when
- // being retried, like an invalid code.
- return we;
}
}, 10, 100);
- if (WebApplicationException.class.isInstance(result)) {
- throw (WebApplicationException) result;
- } else {
- return (Response) result;
- }
}
private Response processGrantRequestInternal() {
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java
index e46a2358229..6d5440a4848 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java
@@ -52,6 +52,10 @@ import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.UserSessionSpi;
+import org.keycloak.models.map.common.AbstractMapProviderFactory;
+import org.keycloak.models.map.storage.MapStorageProviderFactory;
+import org.keycloak.models.map.storage.jpa.JpaMapStorageProviderFactory;
+import org.keycloak.models.map.userSession.MapUserSessionProviderFactory;
import org.keycloak.models.sessions.infinispan.InfinispanUserSessionProviderFactory;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
@@ -73,6 +77,7 @@ import org.hamcrest.Matchers;
import org.keycloak.util.JsonSerialization;
import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
import static org.keycloak.testsuite.util.ServerURLs.AUTH_SERVER_SSL_REQUIRED;
/**
* @author Vlastislav Ramik
@@ -87,7 +92,14 @@ public class ConcurrentLoginTest extends AbstractConcurrencyTest {
@Before
public void beforeTest() {
+ // userSessionProvider is used only to prevent tests from running in certain configs, should be removed once GHI #15410 is resolved.
userSessionProvider = testingClient.server().fetch(session -> Config.getProvider(UserSessionSpi.NAME), String.class);
+ if (userSessionProvider.equals(MapUserSessionProviderFactory.PROVIDER_ID)) {
+ // append the storage provider in case of map
+ String mapStorageProvider = testingClient.server().fetch(session -> Config.scope(UserSessionSpi.NAME,
+ MapUserSessionProviderFactory.PROVIDER_ID, AbstractMapProviderFactory.CONFIG_STORAGE).get("provider"), String.class);
+ if (mapStorageProvider != null) userSessionProvider = userSessionProvider + "-" + mapStorageProvider;
+ }
createClients();
}
@@ -114,9 +126,11 @@ public class ConcurrentLoginTest extends AbstractConcurrencyTest {
@Test
public void concurrentLoginSingleUser() throws Throwable {
- Assume.assumeThat("Test runs only with InfinispanUserSessionProvider",
+ // remove this restriction once GHI #15410 is resolved.
+ Assume.assumeThat("Test runs only with InfinispanUserSessionProvider or MapUserSessionProvider using JPA",
userSessionProvider,
- Matchers.is(InfinispanUserSessionProviderFactory.PROVIDER_ID));
+ Matchers.either(equalTo(InfinispanUserSessionProviderFactory.PROVIDER_ID))
+ .or(equalTo(MapUserSessionProviderFactory.PROVIDER_ID + "-" + JpaMapStorageProviderFactory.PROVIDER_ID)));
log.info("*********************************************");
long start = System.currentTimeMillis();
@@ -182,9 +196,11 @@ public class ConcurrentLoginTest extends AbstractConcurrencyTest {
@Test
public void concurrentLoginMultipleUsers() throws Throwable {
- Assume.assumeThat("Test runs only with InfinispanUserSessionProvider",
+ // remove this restriction once GHI #15410 is resolved.
+ Assume.assumeThat("Test runs only with InfinispanUserSessionProvider or MapUserSessionProvider using JPA",
userSessionProvider,
- Matchers.is(InfinispanUserSessionProviderFactory.PROVIDER_ID));
+ Matchers.either(equalTo(InfinispanUserSessionProviderFactory.PROVIDER_ID))
+ .or(equalTo(MapUserSessionProviderFactory.PROVIDER_ID + "-" + JpaMapStorageProviderFactory.PROVIDER_ID)));
log.info("*********************************************");
long start = System.currentTimeMillis();