Redirect error to client right-away when browser tab detects that another browser tab authenticated

closes #27880

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda 2024-04-08 21:21:40 +02:00 committed by Marek Posolda
parent 9774deda6c
commit aa619f0170
8 changed files with 119 additions and 197 deletions

View file

@ -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()

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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<String> remainingTabs;
public String getAuthSessionId() {
return authSessionId;
}
public void setAuthSessionId(String authSessionId) {
this.authSessionId = authSessionId;
}
public Set<String> getRemainingTabs() {
return remainingTabs;
}
public void setRemainingTabs(Set<String> 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();
}
}

View file

@ -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);
}
}

View file

@ -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<String> 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) {

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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());

View file

@ -42,17 +42,13 @@
<script src="${script}" type="text/javascript"></script>
</#list>
</#if>
<#if authenticationSession??>
<script type="module">
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
<script type="module">
import { checkCookiesAndSetTimer } from "${url.resourcesPath}/js/authChecker.js";
checkCookiesAndSetTimer(
"${authenticationSession.authSessionId}",
"${authenticationSession.tabId}",
"${url.ssoLoginInOtherTabsUrl?no_esc}"
);
</script>
</#if>
checkCookiesAndSetTimer(
"${url.ssoLoginInOtherTabsUrl?no_esc}"
);
</script>
</head>
<body class="${properties.kcBodyClass!}">