diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java
index 05b1eba3c84..5f9138b6b76 100755
--- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java
+++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java
@@ -139,6 +139,14 @@ public interface LoginFormsProvider extends Provider {
LoginFormsProvider setInfo(String message, Object ... parameters);
+ LoginFormsProvider setMessage(MessageType type, String message, Object... parameters);
+
+ /**
+ * Used when authenticationSession was already removed for this browser session and hence we don't have any
+ * authenticationSession or user data. Would just repeat previous info/error page after language is changed
+ */
+ LoginFormsProvider setDetachedAuthSession();
+
LoginFormsProvider setUser(UserModel user);
LoginFormsProvider setResponseHeader(String headerName, String headerValue);
diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java b/server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java
new file mode 100755
index 00000000000..0a6d2f72e57
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/forms/login/MessageType.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 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.forms.login;
+
+/**
+ * Enum with types of messages.
+ *
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public enum MessageType {
+
+ SUCCESS, WARNING, INFO, ERROR
+
+}
diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java
new file mode 100644
index 00000000000..9aecd3d48c1
--- /dev/null
+++ b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateChecker.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2023 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.forms.login.freemarker;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import jakarta.ws.rs.core.UriInfo;
+import org.jboss.logging.Logger;
+import org.keycloak.common.VerificationException;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.services.managers.AuthenticationManager;
+import org.keycloak.services.util.CookieHelper;
+
+/**
+ * @author Marek Posolda
+ */
+public class DetachedInfoStateChecker {
+
+ private static final Logger logger = Logger.getLogger(DetachedInfoStateChecker.class);
+
+ private static final String STATE_CHECKER_COOKIE_NAME = "KC_STATE_CHECKER";
+ public static final String STATE_CHECKER_PARAM = "kc_state_checker";
+
+ private final KeycloakSession session;
+ private final RealmModel realm;
+
+ public DetachedInfoStateChecker(KeycloakSession session, RealmModel realm) {
+ this.session = session;
+ this.realm = realm;
+ }
+
+ public DetachedInfoStateCookie generateAndSetCookie(String messageKey, String messageType, Integer status, String clientId, Object[] messageParameters) {
+ UriInfo uriInfo = session.getContext().getHttpRequest().getUri();
+ String path = AuthenticationManager.getRealmCookiePath(realm, uriInfo);
+ boolean secureOnly = realm.getSslRequired().isRequired(session.getContext().getConnection());
+
+ String currentStateCheckerInUrl = uriInfo.getQueryParameters().getFirst(STATE_CHECKER_PARAM);
+ String newStateChecker = KeycloakModelUtils.generateId();
+ int cookieMaxAge = realm.getAccessCodeLifespanUserAction();
+
+ DetachedInfoStateCookie cookie = new DetachedInfoStateCookie();
+ cookie.setMessageKey(messageKey);
+ cookie.setMessageType(messageType);
+ cookie.setStatus(status);
+ cookie.setClientUuid(clientId);
+ cookie.setCurrentUrlState(currentStateCheckerInUrl);
+ cookie.setRenderedUrlState(newStateChecker);
+ if (messageParameters != null) {
+ List params = Stream.of(messageParameters)
+ .map(Object::toString)
+ .collect(Collectors.toList());
+ cookie.setMessageParameters(params);
+ }
+
+ String encoded = session.tokens().encode(cookie);
+ logger.tracef("Generating new %s cookie. Cookie: %s, Cookie lifespan: %d", STATE_CHECKER_COOKIE_NAME, cookie, cookieMaxAge);
+
+ CookieHelper.addCookie(STATE_CHECKER_COOKIE_NAME, encoded, path, null, null, cookieMaxAge, secureOnly, true, session);
+ return cookie;
+ }
+
+ public DetachedInfoStateCookie verifyStateCheckerParameter(String stateCheckerParam) throws VerificationException {
+ Set cookieVal = CookieHelper.getCookieValue(session, STATE_CHECKER_COOKIE_NAME);
+ if (cookieVal == null || cookieVal.isEmpty()) {
+ throw new VerificationException("State checker cookie is empty");
+ }
+ if (stateCheckerParam == null || stateCheckerParam.isEmpty()) {
+ throw new VerificationException("State checker parameter is empty");
+ }
+
+ String cookieEncoded = cookieVal.iterator().next();
+ DetachedInfoStateCookie cookie = session.tokens().decode(cookieEncoded, DetachedInfoStateCookie.class);
+ if (cookie == null) {
+ throw new VerificationException("Failed to verify DetachedInfoStateCookie");
+ }
+
+ // May want to compare with the currentUrlState (when refreshing detached info/error page) or with renderedUrlState (when user changes locale on the info/error page through the combobox).
+ // As the currentUrlState is in the browser URL when renderedUrlState is in the link inside the user's combobox
+ if (stateCheckerParam.equals(cookie.getCurrentUrlState()) || stateCheckerParam.equals(cookie.getRenderedUrlState())) {
+ return cookie;
+ } else {
+ throw new VerificationException(String.format("Failed to verify state. StateCheckerParameter: %s, cookie current state checker: %s, Cookie rendered state checker: %s",
+ stateCheckerParam, cookie.getCurrentUrlState(), cookie.getRenderedUrlState()));
+ }
+ }
+}
diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java
new file mode 100644
index 00000000000..c577067e398
--- /dev/null
+++ b/services/src/main/java/org/keycloak/forms/login/freemarker/DetachedInfoStateCookie.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2023 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.forms.login.freemarker;
+
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.keycloak.Token;
+import org.keycloak.TokenCategory;
+
+/**
+ * Cookie encapsulating data to be displayed on the info/error page. We need this data due the fact that authenticationSession may not exists.
+ * This is needed so the info/error page can be restored after user changed language.
+ *
+ * @author Marek Posolda
+ */
+public class DetachedInfoStateCookie implements Token {
+
+ @JsonProperty("mky")
+ private String messageKey;
+
+ @JsonProperty("mty")
+ private String messageType;
+
+ @JsonProperty("mpar")
+ private List messageParameters;
+
+ @JsonProperty("stat")
+ private Integer status;
+
+ @JsonProperty("clid")
+ private String clientUuid;
+
+ @JsonProperty("st1")
+ private String currentUrlState;
+
+ @JsonProperty("st2")
+ private String renderedUrlState;
+
+ public String getMessageKey() {
+ return messageKey;
+ }
+
+ public void setMessageKey(String messageKey) {
+ this.messageKey = messageKey;
+ }
+
+ public String getMessageType() {
+ return messageType;
+ }
+
+ public void setMessageType(String messageType) {
+ this.messageType = messageType;
+ }
+
+ public List getMessageParameters() {
+ return messageParameters;
+ }
+
+ public void setMessageParameters(List messageParameters) {
+ this.messageParameters = messageParameters;
+ }
+
+ public Integer getStatus() {
+ return status;
+ }
+
+ public void setStatus(Integer status) {
+ this.status = status;
+ }
+
+ public String getClientUuid() {
+ return clientUuid;
+ }
+
+ public void setClientUuid(String clientUuid) {
+ this.clientUuid = clientUuid;
+ }
+
+ public String getCurrentUrlState() {
+ return currentUrlState;
+ }
+
+ public void setCurrentUrlState(String currentUrlState) {
+ this.currentUrlState = currentUrlState;
+ }
+
+ public String getRenderedUrlState() {
+ return renderedUrlState;
+ }
+
+ public void setRenderedUrlState(String renderedUrlState) {
+ this.renderedUrlState = renderedUrlState;
+ }
+
+ @Override
+ public TokenCategory getCategory() {
+ return TokenCategory.INTERNAL;
+ }
+
+ @Override
+ public String toString() {
+ return new StringBuilder("DetachedInfoStateCookie [ ")
+ .append("messageKey=" + messageKey)
+ .append(", messageType=" + messageType)
+ .append(", status=" + status)
+ .append(", clientUuid=" + clientUuid)
+ .append(", messageParameters=" + messageParameters)
+ .append(", currentUrlState=" + currentUrlState)
+ .append(", renderedUrlState=" + renderedUrlState)
+ .append(" ]")
+ .toString();
+ }
+}
diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java
index e43ca28aec4..4dde78160e2 100755
--- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java
+++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java
@@ -86,7 +86,7 @@ import org.keycloak.theme.beans.AdvancedMessageFormatterMethod;
import org.keycloak.theme.beans.LocaleBean;
import org.keycloak.theme.beans.MessageBean;
import org.keycloak.theme.beans.MessageFormatterMethod;
-import org.keycloak.theme.beans.MessageType;
+import org.keycloak.forms.login.MessageType;
import org.keycloak.theme.beans.MessagesPerFieldBean;
import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.userprofile.UserProfileContext;
@@ -112,6 +112,7 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
protected MessageType messageType = MessageType.ERROR;
protected MultivaluedMap formData;
+ protected boolean detachedAuthSession = false;
protected KeycloakSession session;
/** authenticationSession can be null for some renderings, mainly error pages */
@@ -490,6 +491,22 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
case LOGOUT_CONFIRM:
b = UriBuilder.fromUri(Urls.logoutConfirm(baseUri, realm.getName()));
break;
+ case INFO:
+ case ERROR:
+ if (isDetachedAuthenticationSession()) {
+ FormMessage formMessage = getFirstMessage();
+ if (formMessage == null) {
+ throw new IllegalStateException("Not able to create info/error page with detached authentication session as no info/error message available");
+ }
+
+ DetachedInfoStateCookie cookie = new DetachedInfoStateChecker(session, realm).generateAndSetCookie(
+ formMessage.getMessage(), messageType.toString(), status == null ? null : status.getStatusCode(),
+ client == null ? null : client.getId(), formMessage.getParameters());
+
+ b = UriBuilder.fromUri(Urls.loginActionsDetachedInfo(baseUri, realm.getName()))
+ .queryParam(DetachedInfoStateChecker.STATE_CHECKER_PARAM, cookie.getRenderedUrlState());
+ break;
+ }
default:
b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath());
break;
@@ -703,17 +720,24 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
return createResponse(LoginFormsPages.LOGOUT_CONFIRM);
}
- protected void setMessage(MessageType type, String message, Object... parameters) {
+ @Override
+ public LoginFormsProvider setMessage(MessageType type, String message, Object... parameters) {
messageType = type;
messages = new ArrayList<>();
messages.add(new FormMessage(null, message, parameters));
+ return this;
+ }
+
+ private FormMessage getFirstMessage() {
+ if (messages != null && !messages.isEmpty()) {
+ return messages.get(0);
+ }
+ return null;
}
protected String getFirstMessageUnformatted() {
- if (messages != null && !messages.isEmpty()) {
- return messages.get(0).getMessage();
- }
- return null;
+ FormMessage formMessage = getFirstMessage();
+ return formMessage == null ? null : formMessage.getMessage();
}
protected String formatMessage(FormMessage message, Properties messagesBundle, Locale locale) {
@@ -783,6 +807,16 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
return this;
}
+ @Override
+ public LoginFormsProvider setDetachedAuthSession() {
+ detachedAuthSession = true;
+ return this;
+ }
+
+ private boolean isDetachedAuthenticationSession() {
+ return detachedAuthSession || authenticationSession == null;
+ }
+
@Override
public LoginFormsProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession) {
this.authenticationSession = authenticationSession;
diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java
index 154dccd3d74..266ffc54e2e 100755
--- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java
+++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java
@@ -75,14 +75,6 @@ public class UrlBean {
return Urls.realmRegisterPage(baseURI, realm).toString();
}
- public String getLoginUpdatePasswordUrl() {
- return Urls.loginActionUpdatePassword(baseURI, realm).toString();
- }
-
- public String getLoginUpdateTotpUrl() {
- return Urls.loginActionUpdateTotp(baseURI, realm).toString();
- }
-
public String getLoginUpdateProfileUrl() {
return Urls.loginActionUpdateProfile(baseURI, realm).toString();
}
diff --git a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java
index 20b86e4ea32..6b377b27dcb 100644
--- a/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java
+++ b/services/src/main/java/org/keycloak/locale/DefaultLocaleSelectorProvider.java
@@ -92,11 +92,7 @@ public class DefaultLocaleSelectorProvider implements LocaleSelectorProvider {
}
private Locale getUserSelectedLocale(RealmModel realm, AuthenticationSessionModel session) {
- if (session == null) {
- return null;
- }
-
- String locale = session.getAuthNote(USER_REQUEST_LOCALE);
+ String locale = session == null ? this.session.getAttribute(USER_REQUEST_LOCALE, String.class) : session.getAuthNote(USER_REQUEST_LOCALE);
if (locale == null) {
return null;
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java
index dd88b21de79..b2540aaab07 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/LogoutUtil.java
@@ -28,7 +28,6 @@ import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
-import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.sessions.AuthenticationSessionModel;
@@ -51,7 +50,9 @@ public class LogoutUtil {
if (usedSystemClient) {
loginForm.setAttribute(Constants.SKIP_LINK, true);
}
- return loginForm.createInfoPage();
+ return loginForm
+ .setDetachedAuthSession()
+ .createInfoPage();
}
diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java
index 4057e382b69..c9d5a34add6 100755
--- a/services/src/main/java/org/keycloak/services/Urls.java
+++ b/services/src/main/java/org/keycloak/services/Urls.java
@@ -107,12 +107,8 @@ public class Urls {
return realmLogout(baseUri).path(LogoutEndpoint.class, "logoutConfirmAction").build(realmName);
}
- public static URI loginActionUpdatePassword(URI baseUri, String realmName) {
- return loginActionsBase(baseUri).path(LoginActionsService.class, "updatePassword").build(realmName);
- }
-
- public static URI loginActionUpdateTotp(URI baseUri, String realmName) {
- return loginActionsBase(baseUri).path(LoginActionsService.class, "updateTotp").build(realmName);
+ public static URI loginActionsDetachedInfo(URI baseUri, String realmName) {
+ return loginActionsBase(baseUri).path(LoginActionsService.class, "detachedInfo").build(realmName);
}
public static UriBuilder requiredActionBase(URI baseUri) {
diff --git a/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java b/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java
index 7de50ff7313..4d76aba3a18 100644
--- a/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java
+++ b/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java
@@ -16,7 +16,7 @@ import org.keycloak.theme.Theme;
import org.keycloak.theme.beans.LocaleBean;
import org.keycloak.theme.beans.MessageBean;
import org.keycloak.theme.beans.MessageFormatterMethod;
-import org.keycloak.theme.beans.MessageType;
+import org.keycloak.forms.login.MessageType;
import org.keycloak.theme.freemarker.FreeMarkerProvider;
import org.keycloak.utils.MediaType;
import org.keycloak.utils.MediaTypeMatcher;
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 ccfd4ec379e..89c4515a2ef 100755
--- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
@@ -1072,6 +1072,7 @@ public class AuthenticationManager {
infoPage.setAttribute(Constants.SKIP_LINK, true);
}
Response response = infoPage
+ .setDetachedAuthSession()
.createInfoPage();
new AuthenticationSessionManager(session).removeAuthenticationSession(authSession.getRealm(), authSession, true);
diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java
index 2c26caff058..e2f4180ac6a 100644
--- a/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java
@@ -18,8 +18,8 @@
package org.keycloak.services.managers;
import org.jboss.logging.Logger;
-import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
+import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@@ -222,6 +222,9 @@ public class AuthenticationSessionManager {
if (expireRestartCookie) {
UriInfo uriInfo = session.getContext().getUri();
RestartLoginCookie.expireRestartCookie(realm, uriInfo, session);
+
+ // With browser session, this makes sure that info/error pages will be rendered correctly when locale is changed on them
+ session.getProvider(LoginFormsProvider.class).setDetachedAuthSession();
}
}
diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
index d10cea04c0d..ec8e0d85cce 100755
--- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
+++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
@@ -19,6 +19,10 @@ package org.keycloak.services.resources;
import org.jboss.logging.Logger;
import org.keycloak.common.Profile;
import org.keycloak.common.util.ResponseSessionTask;
+import org.keycloak.forms.login.LoginFormsProvider;
+import org.keycloak.forms.login.MessageType;
+import org.keycloak.forms.login.freemarker.DetachedInfoStateChecker;
+import org.keycloak.forms.login.freemarker.DetachedInfoStateCookie;
import org.keycloak.http.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
@@ -122,6 +126,8 @@ public class LoginActionsService {
public static final String RESTART_PATH = "restart";
+ public static final String DETACHED_INFO_PATH = "detached-info";
+
public static final String FORWARDED_ERROR_MESSAGE_NOTE = "forwardedErrorMessage";
public static final String SESSION_CODE = "session_code";
@@ -243,6 +249,50 @@ public class LoginActionsService {
return Response.status(Response.Status.FOUND).location(redirectUri).build();
}
+ /**
+ * protocol independent "detached info" page. Shown when locale is changed by user on info/error page
+ * after authenticationSession was already removed.
+ *
+ * @return
+ */
+ @Path(DETACHED_INFO_PATH)
+ @GET
+ public Response detachedInfo(@QueryParam(DetachedInfoStateChecker.STATE_CHECKER_PARAM) String stateCheckerParam) {
+ DetachedInfoStateCookie cookie;
+ try {
+ cookie = new DetachedInfoStateChecker(session, realm).verifyStateCheckerParameter(stateCheckerParam);
+ logger.tracef("Detached info endpoint invoked and cookie successfully verified. StateCheckerParam=%s, StateCookie=%s", stateCheckerParam, cookie);
+ } catch (VerificationException ve) {
+ logger.warn(ve.getMessage());
+ return ErrorPage.error(session, null, Response.Status.BAD_REQUEST, Messages.EXPIRED_ACTION_TOKEN_NO_SESSION);
+ }
+
+ processLocaleParam(null);
+
+ boolean skipLink = true;
+ if (cookie.getClientUuid() != null) {
+ ClientModel client = session.clients().getClientById(realm, cookie.getClientUuid());
+ if (client != null) {
+ session.getContext().setClient(client);
+ skipLink = client.equals(SystemClientUtil.getSystemClient(realm));
+ }
+ }
+
+ MessageType type = Enum.valueOf(MessageType.class, cookie.getMessageType());
+ Response.Status statusObj = cookie.getStatus() == null ? Response.Status.BAD_REQUEST : Response.Status.fromStatusCode(cookie.getStatus());
+ Object[] paramsAsObject = cookie.getMessageParameters() == null ? null : cookie.getMessageParameters().toArray();
+
+ LoginFormsProvider loginForm = session.getProvider(LoginFormsProvider.class)
+ .setDetachedAuthSession()
+ .setMessage(type, cookie.getMessageKey(), paramsAsObject);
+
+ if (skipLink) {
+ loginForm.setAttribute(Constants.SKIP_LINK, true);
+ }
+
+ return type == MessageType.ERROR ? loginForm.createErrorPage(statusObj) : loginForm.createInfoPage();
+ }
+
/**
* protocol independent login page entry point
diff --git a/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java b/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java
index 854b242bd80..26d9778bdea 100644
--- a/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java
+++ b/services/src/main/java/org/keycloak/services/resources/SessionCodeChecks.java
@@ -222,6 +222,7 @@ public class SessionCodeChecks {
ClientModel client = authSession.getClient();
if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND);
+ session.getProvider(LoginFormsProvider.class).setDetachedAuthSession();
response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.UNKNOWN_LOGIN_REQUESTER);
clientCode.removeExpiredClientSession();
return false;
@@ -232,6 +233,7 @@ public class SessionCodeChecks {
if (checkClientDisabled(client)) {
event.error(Errors.CLIENT_DISABLED);
+ session.getProvider(LoginFormsProvider.class).setDetachedAuthSession();
response = ErrorPage.error(session, authSession, Response.Status.BAD_REQUEST, Messages.LOGIN_REQUESTER_NOT_ENABLED);
clientCode.removeExpiredClientSession();
return false;
diff --git a/services/src/main/java/org/keycloak/services/util/LocaleUtil.java b/services/src/main/java/org/keycloak/services/util/LocaleUtil.java
index 7f541b53352..09ad7d4e1e1 100644
--- a/services/src/main/java/org/keycloak/services/util/LocaleUtil.java
+++ b/services/src/main/java/org/keycloak/services/util/LocaleUtil.java
@@ -43,10 +43,15 @@ public class LocaleUtil {
}
public static void processLocaleParam(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authSession) {
- if (authSession != null && realm.isInternationalizationEnabled()) {
+ if (realm.isInternationalizationEnabled()) {
String locale = session.getContext().getUri().getQueryParameters().getFirst(LocaleSelectorProvider.KC_LOCALE_PARAM);
if (locale != null) {
- authSession.setAuthNote(LocaleSelectorProvider.USER_REQUEST_LOCALE, locale);
+ if (authSession != null) {
+ authSession.setAuthNote(LocaleSelectorProvider.USER_REQUEST_LOCALE, locale);
+ } else {
+ // Might be on info/error page when we don't have authenticationSession
+ session.setAttribute(LocaleSelectorProvider.USER_REQUEST_LOCALE, locale);
+ }
LocaleUpdaterProvider localeUpdater = session.getProvider(LocaleUpdaterProvider.class);
localeUpdater.updateLocaleCookie(locale);
diff --git a/services/src/main/java/org/keycloak/theme/beans/MessageBean.java b/services/src/main/java/org/keycloak/theme/beans/MessageBean.java
index a8e76913ded..baba798b2fd 100755
--- a/services/src/main/java/org/keycloak/theme/beans/MessageBean.java
+++ b/services/src/main/java/org/keycloak/theme/beans/MessageBean.java
@@ -16,6 +16,8 @@
*/
package org.keycloak.theme.beans;
+import org.keycloak.forms.login.MessageType;
+
/**
* @author Stian Thorgersen
*/
diff --git a/services/src/main/java/org/keycloak/theme/beans/MessageType.java b/services/src/main/java/org/keycloak/theme/beans/MessageType.java
deleted file mode 100755
index da378166201..00000000000
--- a/services/src/main/java/org/keycloak/theme/beans/MessageType.java
+++ /dev/null
@@ -1,28 +0,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.
- */
-package org.keycloak.theme.beans;
-
-/**
- * Enum with types of messages.
- *
- * @author Vlastimil Elias (velias at redhat dot com)
- */
-public enum MessageType {
-
- SUCCESS, WARNING, INFO, ERROR
-
-}
diff --git a/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java b/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java
index e846cb9c13d..49112dfeea7 100755
--- a/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java
+++ b/services/src/main/java/org/keycloak/theme/beans/MessagesPerFieldBean.java
@@ -19,6 +19,8 @@ package org.keycloak.theme.beans;
import java.util.HashMap;
import java.util.Map;
+import org.keycloak.forms.login.MessageType;
+
/**
* Bean used to hold form messages per field. Stored under messagesPerField key in Freemarker context.
*
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java
index a72d26752f9..993f5a6cde6 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/ErrorPage.java
@@ -26,7 +26,7 @@ import org.openqa.selenium.support.FindBy;
/**
* @author Stian Thorgersen
*/
-public class ErrorPage extends AbstractPage {
+public class ErrorPage extends LanguageComboboxAwarePage {
@ArquillianResource
protected OAuthClient oauth;
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java
index 8ed08ed2d70..e5170892cbe 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java
@@ -146,6 +146,7 @@ public class EmailTest extends AbstractI18NTest {
}
//KEYCLOAK-7478
+ // Issue 13922
@Test
public void changeLocaleOnInfoPage() throws InterruptedException, IOException {
UserResource testUser = ApiUtil.findUserByUsernameId(testRealm(), "login-test");
@@ -178,5 +179,15 @@ public class EmailTest extends AbstractI18NTest {
Assert.assertTrue("Expected to be on InfoPage, but it was on " + DroneUtils.getCurrentDriver().getTitle(), infoPage.isCurrent());
assertThat(infoPage.getInfo(), containsString("Your account has been updated."));
+
+ // Change language again when on final info page with the message about updated account (authSession removed already at this point)
+ infoPage.openLanguage("Deutsch");
+ assertEquals("Deutsch", infoPage.getLanguageDropdownText());
+ assertThat(infoPage.getInfo(), containsString("Ihr Benutzerkonto wurde aktualisiert."));
+
+ infoPage.openLanguage("English");
+ assertEquals("English", infoPage.getLanguageDropdownText());
+ assertThat(infoPage.getInfo(), containsString("Your account has been updated."));
+
}
}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java
index ef8a5c2ac35..c8e9b2862bc 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/LoginPageTest.java
@@ -17,6 +17,8 @@
package org.keycloak.testsuite.i18n;
import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Locale;
@@ -29,12 +31,15 @@ import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.adapters.HttpClientBuilder;
import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.common.util.KeycloakUriBuilder;
+import org.keycloak.forms.login.freemarker.DetachedInfoStateChecker;
import org.keycloak.locale.LocaleSelectorProvider;
import org.keycloak.models.UserModel;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.AppPage;
+import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.LanguageComboboxAwarePage;
import org.keycloak.testsuite.pages.LoginPage;
@@ -62,6 +67,9 @@ public class LoginPageTest extends AbstractI18NTest {
@Page
protected LoginPage loginPage;
+ @Page
+ protected ErrorPage errorPage;
+
@Page
protected LoginPasswordUpdatePage changePasswordPage;
@@ -255,6 +263,55 @@ public class LoginPageTest extends AbstractI18NTest {
Assert.assertNull(localeCookie);
}
+
+ // Test for user updating locale on the error page (when authenticationSession is not available)
+ @Test
+ public void languageUserUpdatesOnErrorPage() {
+ // Login page with invalid redirect_uri
+ oauth.redirectUri("http://invalid");
+ loginPage.open();
+
+ errorPage.assertCurrent();
+ Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError());
+
+ // Change language should be OK
+ errorPage.openLanguage("Deutsch");
+ assertEquals("Deutsch", errorPage.getLanguageDropdownText());
+ Assert.assertEquals("Ungültiger Parameter: redirect_uri", errorPage.getError());
+
+ // Refresh browser button should keep german language
+ driver.navigate().refresh();
+ assertEquals("Deutsch", errorPage.getLanguageDropdownText());
+ Assert.assertEquals("Ungültiger Parameter: redirect_uri", errorPage.getError());
+
+ // Changing to english should work
+ errorPage.openLanguage("English");
+ assertEquals("English", errorPage.getLanguageDropdownText());
+ Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError());
+ }
+
+ @Test
+ public void languageUserUpdatesOnErrorPageStateCheckerTest() throws URISyntaxException {
+ // Login page with invalid redirect_uri
+ oauth.redirectUri("http://invalid");
+ loginPage.open();
+
+ errorPage.assertCurrent();
+ Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError());
+
+ errorPage.openLanguage("Deutsch");
+ Assert.assertEquals("Ungültiger Parameter: redirect_uri", errorPage.getError());
+
+ // Add incorrect state checker parameter. Error page should be shown about expired action. Language won't be changed
+ String currentUrl = driver.getCurrentUrl();
+ String newUrl = KeycloakUriBuilder.fromUri(new URI(currentUrl))
+ .replaceQueryParam(LocaleSelectorProvider.KC_LOCALE_PARAM, "en")
+ .replaceQueryParam(DetachedInfoStateChecker.STATE_CHECKER_PARAM, "invalid").buildAsString();
+ driver.navigate().to(newUrl);
+
+ Assert.assertEquals("Die Aktion ist nicht mehr gültig.", errorPage.getError()); // Action expired.
+ }
+
@Test
public void realmLocalizationMessagesAreApplied() {
String realmLocalizationMessageKey = "loginAccountTitle";
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java
index b21b0113187..19fc9df2b7f 100644
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RPInitiatedLogoutTest.java
@@ -901,6 +901,19 @@ public class RPInitiatedLogoutTest extends AbstractTestRealmKeycloakTest {
infoPage.assertCurrent();
Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message
+
+ // Change locale on the info page (AuthenticationSession does not exists on server at this point)
+ infoPage.openLanguage("English");
+ Assert.assertEquals("You are logged out", infoPage.getInfo()); // Logout success message
+
+ // Change locale again
+ infoPage.openLanguage("Čeština");
+ Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message
+
+ // Refresh page
+ driver.navigate().refresh();
+ Assert.assertEquals("Odhlášení bylo úspěšné", infoPage.getInfo()); // Logout success message
+
infoPage.clickBackToApplicationLinkCs();
WaitUtils.waitForPageToLoad();
MatcherAssert.assertThat(driver.getCurrentUrl(), endsWith("/app/auth"));