From aa619f01700df79da69f4314c0f1225c14c6cfa1 Mon Sep 17 00:00:00 2001 From: mposolda Date: Mon, 8 Apr 2024 21:21:40 +0200 Subject: [PATCH] Redirect error to client right-away when browser tab detects that another browser tab authenticated closes #27880 Signed-off-by: mposolda --- .../java/org/keycloak/cookie/CookieType.java | 4 - .../freemarker/AuthenticationStateCookie.java | 92 -------------- .../AuthenticationSessionManager.java | 4 - .../org/keycloak/testsuite/AssertEvents.java | 35 +++++- .../cookies/DefaultCookieProviderTest.java | 4 +- .../forms/MultipleTabsLoginTest.java | 115 +++++++++++------- .../base/login/resources/js/authChecker.js | 46 +------ .../resources/theme/base/login/template.ftl | 16 +-- 8 files changed, 119 insertions(+), 197 deletions(-) delete mode 100644 services/src/main/java/org/keycloak/forms/login/freemarker/AuthenticationStateCookie.java diff --git a/server-spi-private/src/main/java/org/keycloak/cookie/CookieType.java b/server-spi-private/src/main/java/org/keycloak/cookie/CookieType.java index 7a81fb5dfb..e30a2042f1 100644 --- a/server-spi-private/src/main/java/org/keycloak/cookie/CookieType.java +++ b/server-spi-private/src/main/java/org/keycloak/cookie/CookieType.java @@ -19,10 +19,6 @@ public final class CookieType { .supportSameSiteLegacy() .build(); - public static final CookieType AUTH_STATE = CookieType.create("KC_AUTH_STATE") - .scope(CookieScope.INTERNAL_JS) - .build(); - public static final CookieType IDENTITY = CookieType.create("KEYCLOAK_IDENTITY") .scope(CookieScope.FEDERATION) .supportSameSiteLegacy() diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/AuthenticationStateCookie.java b/services/src/main/java/org/keycloak/forms/login/freemarker/AuthenticationStateCookie.java deleted file mode 100644 index acaaa6aecd..0000000000 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/AuthenticationStateCookie.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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.io.IOException; -import java.util.Set; - -import com.fasterxml.jackson.annotation.JsonProperty; -import org.jboss.logging.Logger; -import org.keycloak.common.util.Encode; -import org.keycloak.cookie.CookieProvider; -import org.keycloak.cookie.CookieType; -import org.keycloak.models.KeycloakSession; -import org.keycloak.sessions.RootAuthenticationSessionModel; -import org.keycloak.util.JsonSerialization; - -/** - * Non http-only cookie with tracking remaining authSessions in current root authentication session - * - * @author Marek Posolda - */ -public class AuthenticationStateCookie { - - private static final Logger logger = Logger.getLogger(AuthenticationStateCookie.class); - - public static final String KC_AUTH_STATE = "KC_AUTH_STATE"; - - @JsonProperty("authSessionId") - private String authSessionId; - - @JsonProperty("remainingTabs") - private Set remainingTabs; - - public String getAuthSessionId() { - return authSessionId; - } - - public void setAuthSessionId(String authSessionId) { - this.authSessionId = authSessionId; - } - - public Set getRemainingTabs() { - return remainingTabs; - } - - public void setRemainingTabs(Set remainingTabs) { - this.remainingTabs = remainingTabs; - } - - public static void generateAndSetCookie(KeycloakSession session, RootAuthenticationSessionModel rootAuthSession, int cookieMaxAge) { - AuthenticationStateCookie cookie = new AuthenticationStateCookie(); - cookie.setAuthSessionId(rootAuthSession.getId()); - cookie.setRemainingTabs(rootAuthSession.getAuthenticationSessions().keySet()); - - try { - String encoded = Encode.urlEncode(JsonSerialization.writeValueAsString(cookie)); - session.getProvider(CookieProvider.class).set(CookieType.AUTH_STATE, encoded, cookieMaxAge); - } catch (IOException ioe) { - throw new IllegalStateException("Exception thrown when encoding cookie", ioe); - } - } - - public static void expireCookie(KeycloakSession session) { - session.getProvider(CookieProvider.class).expire(CookieType.AUTH_STATE); - } - - @Override - public String toString() { - return new StringBuilder("AuthenticationStateCookie [ ") - .append("authSessionId=" + authSessionId) - .append(", remainingTabs=" + remainingTabs) - .append(" ]") - .toString(); - } -} 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 241501028a..348d421b5f 100644 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationSessionManager.java @@ -22,7 +22,6 @@ import org.keycloak.common.util.Time; import org.keycloak.cookie.CookieProvider; import org.keycloak.cookie.CookieType; import org.keycloak.forms.login.LoginFormsProvider; -import org.keycloak.forms.login.freemarker.AuthenticationStateCookie; import org.keycloak.models.ClientModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -177,7 +176,6 @@ public class AuthenticationSessionManager { // expire restart cookie if (expireRestartCookie) { RestartLoginCookie.expireRestartCookie(session); - AuthenticationStateCookie.expireCookie(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(); @@ -225,8 +223,6 @@ public class AuthenticationSessionManager { rootAuthSession.setTimestamp(authSessionExpirationTime); log.tracef("Removed authentication session of root session '%s' with tabId '%s'. But there are remaining tabs in the root session. Root authentication session will expire in %d seconds", rootAuthSession.getId(), authSession.getTabId(), authSessionExpiresIn); - - AuthenticationStateCookie.generateAndSetCookie(session, rootAuthSession, authSessionExpiresIn); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java index 388e25e6d9..e1621675b8 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java @@ -38,6 +38,7 @@ import org.keycloak.util.TokenUtil; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -359,7 +360,39 @@ public class AssertEvents implements TestRule { } public EventRepresentation assertEvent() { - return assertEvent(poll()); + return assertEvent(false); + } + + /** + * Assert the expected event was sent to the listener by Keycloak server. Returns this event. + * + * @param ignorePreviousEvents if true, test will ignore all the events, which were already present. Test will poll the events from the queue until it finds the event of expected type + * @return the expected event + */ + public EventRepresentation assertEvent(boolean ignorePreviousEvents) { + if (expected.getError() != null && ! expected.getType().endsWith("_ERROR")) { + expected.setType(expected.getType() + "_ERROR"); + } + + if (ignorePreviousEvents) { + // Consider 25 as a "limit" for maximum number of events in the queue for now + List presentedEventTypes = new LinkedList<>(); + for (int i = 0 ; i < 25 ; i++) { + EventRepresentation event = fetchNextEvent(); + if (event == null) { + Assert.fail("Did not find the event of expected type " + expected.getType() +". Events present: " + presentedEventTypes); + } + if (expected.getType().equals(event.getType())) { + return assertEvent(event); + } else { + presentedEventTypes.add(event.getType()); + } + } + Assert.fail("Did not find the event of expected type " + expected.getType() +". Events present: " + presentedEventTypes); + return null; // Unreachable code + } else { + return assertEvent(poll()); + } } public EventRepresentation assertEvent(EventRepresentation actual) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cookies/DefaultCookieProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cookies/DefaultCookieProviderTest.java index 03f0c5b6c4..91ea32b52e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cookies/DefaultCookieProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cookies/DefaultCookieProviderTest.java @@ -42,7 +42,6 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest { Response response = testing.server("master").runWithResponse(session -> { CookieProvider cookies = session.getProvider(CookieProvider.class); cookies.set(CookieType.AUTH_SESSION_ID, "my-auth-session-id"); - cookies.set(CookieType.AUTH_STATE, "my-auth-state", 111); cookies.set(CookieType.AUTH_RESTART, "my-auth-restart"); cookies.set(CookieType.AUTH_DETACHED, "my-auth-detached", 222); cookies.set(CookieType.IDENTITY, "my-identity", 333); @@ -51,9 +50,8 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest { cookies.set(CookieType.SESSION, "my-session", 444); cookies.set(CookieType.WELCOME_CSRF, "my-welcome-csrf"); }); - Assert.assertEquals(12, response.getCookies().size()); + Assert.assertEquals(11, response.getCookies().size()); assertCookie(response, "AUTH_SESSION_ID", "my-auth-session-id", "/auth/realms/master/", -1, false, true, "None", true); - assertCookie(response, "KC_AUTH_STATE", "my-auth-state", "/auth/realms/master/", 111, false, false, "Strict", false); assertCookie(response, "KC_RESTART", "my-auth-restart", "/auth/realms/master/", -1, false, true, "None", false); assertCookie(response, "KC_STATE_CHECKER", "my-auth-detached", "/auth/realms/master/", 222, false, true, "Strict", false); assertCookie(response, "KEYCLOAK_IDENTITY", "my-identity", "/auth/realms/master/", 333, false, true, "None", true); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultipleTabsLoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultipleTabsLoginTest.java index 2327277fff..0688e77119 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultipleTabsLoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/MultipleTabsLoginTest.java @@ -18,6 +18,7 @@ package org.keycloak.testsuite.forms; import static org.hamcrest.MatcherAssert.assertThat; +import static org.keycloak.models.Constants.CLIENT_DATA; import static org.keycloak.testsuite.AssertEvents.DEFAULT_REDIRECT_URI; import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith; @@ -34,12 +35,14 @@ import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; +import org.keycloak.common.util.UriUtils; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.models.Constants; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.RestartLoginCookie; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; @@ -174,12 +177,7 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); // Should be back on tab1 - if (driver instanceof HtmlUnitDriver) { - driver.navigate().refresh(); // Need to explicitly refresh with HtmlUnitDriver due the authChecker.js javascript does not work - } - - // Should be back on tab1 and logged-in automatically here - WaitUtils.waitUntilElement(appPage.getAccountLink()).is().clickable(); + waitForAppPage(() -> driver.navigate().refresh()); appPage.assertCurrent(); } } @@ -189,34 +187,57 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { public void multipleTabsParallelLoginTestWithAuthSessionExpiredInTheMiddle() { try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) { multipleTabsParallelLogin(tabUtil); - events.clear(); - loginPage.login("login-test", "password"); + waitForAppPage(() -> loginPage.login("login-test", "password")); assertOnAppPageWithAlreadyLoggedInError(EventType.LOGIN); } } @Test - public void multipleTabsParallelLoginTestWithAuthSessionExpiredInTheMiddle_badRedirectUri() throws Exception { - try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) { - multipleTabsParallelLogin(tabUtil); + public void testWithAuthSessionExpiredInTheMiddle_badRedirectUri() throws Exception { + oauth.openLoginForm(); + loginPage.assertCurrent(); - // Remove redirectUri from the client - try (ClientAttributeUpdater cap = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") - .setRedirectUris(List.of("https://foo")) - .update()) { + // Simulate incorrect login attempt to make sure that URL is on LoginActionsService URL + loginPage.login("invalid", "invalid"); + String loginUrl = driver.getCurrentUrl(); + Assert.assertTrue(UriUtils.decodeQueryString(new URL(loginUrl).getQuery()).containsKey(CLIENT_DATA)); + getLogger().info("URL in tab1: " + driver.getCurrentUrl()); - events.clear(); - loginPage.login("login-test", "password"); - events.expectLogin().user((String) null).session((String) null).error(Errors.INVALID_REDIRECT_URI) - .detail(Details.RESPONSE_TYPE, OIDCResponseType.CODE) - .detail(Details.RESPONSE_MODE, OIDCResponseMode.QUERY.value()) - .removeDetail(Details.CONSENT) - .removeDetail(Details.CODE_ID) - .assertEvent(); - errorPage.assertCurrent(); // Page "You are already logged in." should not be here - Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); - } + oauth.openLoginForm(); + loginPage.assertCurrent(); + + // Wait until authentication session expires + setTimeOffset(7200000); + + loginPage.login("login-test", "password"); + loginPage.assertCurrent(); + Assert.assertEquals(loginPage.getError(), "Your login attempt timed out. Login will start from the beginning."); + events.clear(); + + loginSuccessAndDoRequiredActions(); + + // Remove redirectUri from the client "test-app" + try (ClientAttributeUpdater cap = ClientAttributeUpdater.forClient(adminClient, "test", "test-app") + .setRedirectUris(List.of("https://foo")) + .update()) { + + events.clear(); + + // Delete cookie and go to original loginURL. Restore from client_data parameter should fail due the incorrect redirectUri + driver.manage().deleteCookieNamed(RestartLoginCookie.KC_RESTART); + + driver.navigate().to(loginUrl); + errorPage.assertCurrent(); + Assert.assertEquals("Invalid parameter: redirect_uri", errorPage.getError()); + + events.expectLogin().user((String) null).session((String) null) + .error(Errors.INVALID_REDIRECT_URI) + .detail(Details.RESPONSE_TYPE, OIDCResponseType.CODE) + .detail(Details.RESPONSE_MODE, OIDCResponseMode.QUERY.value()) + .removeDetail(Details.CONSENT) + .removeDetail(Details.CODE_ID) + .assertEvent(true); } } @@ -239,6 +260,7 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { loginPage.login("login-test", "password"); loginPage.assertCurrent(); Assert.assertEquals(loginPage.getError(), "Your login attempt timed out. Login will start from the beginning."); + events.clear(); loginSuccessAndDoRequiredActions(); @@ -257,13 +279,19 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { // Assert browser was redirected to the appPage with "error=temporarily_unavailable" and error_description corresponding to Constants.AUTHENTICATION_EXPIRED_MESSAGE private void assertOnAppPageWithAlreadyLoggedInError(EventType expectedEventType) { + if (!(driver instanceof HtmlUnitDriver)) { + // In case of real browsers, the "tab2" is automatically refreshed when tab1 finish authentication. This is done by invoking LoginActionsService.restartSession endpoint by JS. + // Hence event type is always RESTART_AUTHENTICATION + expectedEventType = EventType.RESTART_AUTHENTICATION; + } + events.expect(expectedEventType) .user((String) null).error(Errors.ALREADY_LOGGED_IN) .detail(Details.REDIRECT_URI, Matchers.equalTo(DEFAULT_REDIRECT_URI)) .detail(Details.REDIRECTED_TO_CLIENT, "true") .detail(Details.RESPONSE_TYPE, OIDCResponseType.CODE) .detail(Details.RESPONSE_MODE, OIDCResponseMode.QUERY.value()) - .assertEvent(); + .assertEvent(true); appPage.assertCurrent(); // Page "You are already logged in." should not be here OAuthClient.AuthorizationEndpointResponse authzResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); Assert.assertEquals(OAuthErrorException.TEMPORARILY_UNAVAILABLE, authzResponse.getError()); @@ -274,9 +302,8 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { public void multipleTabsParallelLoginTestWithAuthSessionExpiredAndRegisterClick() { try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) { multipleTabsParallelLogin(tabUtil); - events.clear(); - loginPage.clickRegister(); + waitForAppPage(() -> loginPage.clickRegister()); assertOnAppPageWithAlreadyLoggedInError(EventType.REGISTER); } } @@ -285,9 +312,8 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { public void multipleTabsParallelLoginTestWithAuthSessionExpiredAndResetPasswordClick() { try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) { multipleTabsParallelLogin(tabUtil); - events.clear(); - loginPage.resetPassword(); + waitForAppPage(() -> loginPage.resetPassword()); assertOnAppPageWithAlreadyLoggedInError(EventType.RESET_PASSWORD); } } @@ -322,9 +348,8 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { // Go back to tab1. Usually should be automatically authenticated here (previously it showed "You are already logged-in") tabUtil.closeTab(1); assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); - events.clear(); - updatePasswordPage.changePassword("password", "password"); + waitForAppPage(() -> updatePasswordPage.changePassword("password", "password")); assertOnAppPageWithAlreadyLoggedInError(EventType.CUSTOM_REQUIRED_ACTION); } } @@ -359,9 +384,11 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { // Go back to tab1 and refresh the page. Should be automatically authenticated here (previously it showed "You are already logged-in") tabUtil.closeTab(1); assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); - events.clear(); - driver.navigate().refresh(); + waitForAppPage(() -> { + events.clear(); + driver.navigate().refresh(); + }); assertOnAppPageWithAlreadyLoggedInError(EventType.LOGIN); } } @@ -660,13 +687,19 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest { tabUtil.closeTab(1); assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(1)); - if (driver instanceof HtmlUnitDriver) { - driver.navigate().refresh(); // Need to explicitly refresh with HtmlUnitDriver due the authChecker.js javascript does not work - } - - // Should be back on tab1 and logged-in automatically here - WaitUtils.waitUntilElement(appPage.getAccountLink()).is().clickable(); + waitForAppPage(() -> driver.navigate().refresh()); appPage.assertCurrent(); } } + + private void waitForAppPage(Runnable htmlUnitAction) { + if (driver instanceof HtmlUnitDriver) { + // authChecker.js javascript does not work with HtmlUnitDriver. So need to "refresh" the current browser tab by running the last action in order to simulate "already_logged_in" + // error and being redirected to client + htmlUnitAction.run(); + } + + // Should be back on tab1 and logged-in automatically here + WaitUtils.waitUntilElement(appPage.getAccountLink()).is().clickable(); + } } diff --git a/themes/src/main/resources/theme/base/login/resources/js/authChecker.js b/themes/src/main/resources/theme/base/login/resources/js/authChecker.js index 18fd8f9520..c5938c3e89 100644 --- a/themes/src/main/resources/theme/base/login/resources/js/authChecker.js +++ b/themes/src/main/resources/theme/base/login/resources/js/authChecker.js @@ -1,7 +1,7 @@ const CHECK_INTERVAL_MILLISECS = 2000; const initialSession = getSession(); -export function checkCookiesAndSetTimer(authSessionId, tabId, loginRestartUrl) { +export function checkCookiesAndSetTimer(loginRestartUrl) { if (initialSession) { // We started with a session, so there is nothing to do, exit. return; @@ -12,57 +12,19 @@ export function checkCookiesAndSetTimer(authSessionId, tabId, loginRestartUrl) { if (!session) { // The session is not present, check again later. setTimeout( - () => checkCookiesAndSetTimer(authSessionId, tabId, loginRestartUrl), + () => checkCookiesAndSetTimer(loginRestartUrl), CHECK_INTERVAL_MILLISECS ); } else { - // The session is present, check the auth state. - checkAuthState(authSessionId, tabId, loginRestartUrl); + // Redirect to the login restart URL. This can typically automatically login user due the SSO + location.href = loginRestartUrl; } } -function checkAuthState(authSessionId, tabId, loginRestartUrl) { - const authStateRaw = getAuthState(); - - if (!authStateRaw) { - // The auth state is not present, exit. - return; - } - - // Attempt to parse the auth state as JSON. - let authState; - try { - authState = JSON.parse(decodeURIComponent(authStateRaw)); - } catch (error) { - // The auth state is not valid JSON, exit. - return; - } - - if (authState.authSessionId !== authSessionId) { - // The session ID does not match, exit. - return; - } - - if ( - !Array.isArray(authState.remainingTabs) || - !authState.remainingTabs.includes(tabId) - ) { - // The remaining tabs don't include the provided tab ID, exit. - return; - } - - // We made it this far, redirect to the login restart URL. - location.href = loginRestartUrl; -} - function getSession() { return getCookieByName("KEYCLOAK_SESSION"); } -function getAuthState() { - return getCookieByName("KC_AUTH_STATE"); -} - function getCookieByName(name) { for (const cookie of document.cookie.split(";")) { const [key, value] = cookie.split("=").map((value) => value.trim()); diff --git a/themes/src/main/resources/theme/base/login/template.ftl b/themes/src/main/resources/theme/base/login/template.ftl index 4079a0a9b9..c373c02e4c 100644 --- a/themes/src/main/resources/theme/base/login/template.ftl +++ b/themes/src/main/resources/theme/base/login/template.ftl @@ -42,17 +42,13 @@ - <#if authenticationSession??> - - + checkCookiesAndSetTimer( + "${url.ssoLoginInOtherTabsUrl?no_esc}" + ); +